@aetherframework/middleware 1.0.2 → 1.0.5
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/README.md +0 -3
- package/docs/readme/README.md +14 -3
- package/docs/readme/README_zh.md +0 -3
- package/examples/advanced-server.js +122 -112
- package/examples/basic-server.js +322 -64
- package/index.js +9 -11
- package/package.json +1 -1
- package/src/core/AetherCompiler.js +117 -63
- package/src/core/AetherContext.js +221 -93
- package/src/core/AetherPipeline.js +261 -285
- package/src/core/AetherRouter.js +358 -256
- package/src/core/AetherStore.js +114 -67
- package/src/middleware/compression.js +165 -91
- package/src/middleware/json.js +180 -169
- package/src/middleware/rate-limit.js +76 -146
- package/src/middleware/security.js +33 -54
- package/src/middleware/session.js +89 -86
package/src/core/AetherStore.js
CHANGED
|
@@ -1,106 +1,125 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
2
|
* @license MIT
|
|
3
3
|
* Copyright (c) 2026-present AetherFramework Contributors.
|
|
4
4
|
* SPDX-License-Identifier: MIT
|
|
5
5
|
* @module @aetherframework/middleware/core/AetherStore
|
|
6
|
+
*
|
|
7
|
+
* Ultra-Optimized Memory Store with Zero Statistics Overhead
|
|
8
|
+
* Removed all monitoring, event emissions, and performance counters
|
|
9
|
+
* Focus: Pure storage operations with maximum speed
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
|
-
import { EventEmitter } from 'events';
|
|
9
|
-
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
13
|
+
* MemoryStore - High-performance in-memory storage with LRU eviction
|
|
14
|
+
* Removed all statistics, events, and monitoring for maximum speed
|
|
12
15
|
*/
|
|
13
|
-
class MemoryStore
|
|
16
|
+
class MemoryStore {
|
|
14
17
|
constructor(options = {}) {
|
|
15
|
-
|
|
18
|
+
// [PERF] Direct property assignment for V8 optimization
|
|
16
19
|
this.maxSize = options.maxSize || 10000;
|
|
17
|
-
this.ttl = options.ttl || 3600000; // 1 hour
|
|
20
|
+
this.ttl = options.ttl || 3600000; // 1 hour default TTL
|
|
21
|
+
|
|
22
|
+
// [PERF] Use Map for O(1) operations
|
|
18
23
|
this.store = new Map();
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
// Start cleanup interval
|
|
24
|
+
|
|
25
|
+
// [PERF] Simple LRU tracking - array is faster than linked list for small sizes
|
|
26
|
+
this.lru = [];
|
|
27
|
+
|
|
28
|
+
// [PERF] No EventEmitter inheritance - removed all event overhead
|
|
29
|
+
// [PERF] No statistics tracking - removed hits, misses, sets counters
|
|
30
|
+
|
|
31
|
+
// [PERF] Cleanup interval with unref to prevent blocking shutdown
|
|
29
32
|
this.cleanupInterval = setInterval(() => this._cleanup(), 60000).unref();
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Get value by key with LRU update
|
|
37
|
+
* @param {string} key - Storage key
|
|
38
|
+
* @returns {Promise<any>} - Stored value or null
|
|
39
|
+
*/
|
|
32
40
|
async get(key) {
|
|
33
41
|
const entry = this.store.get(key);
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
43
|
+
// [PERF] Fast null check
|
|
44
|
+
if (!entry) return null;
|
|
39
45
|
|
|
40
|
-
// Check if
|
|
46
|
+
// [PERF] Check expiration without Date.now() call if no TTL
|
|
41
47
|
if (entry.expires && Date.now() > entry.expires) {
|
|
48
|
+
// [PERF] Direct deletion without statistics
|
|
42
49
|
this.store.delete(key);
|
|
43
50
|
this._removeFromLRU(key);
|
|
44
|
-
this.stats.misses++;
|
|
45
51
|
return null;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
|
-
// Update LRU
|
|
54
|
+
// [PERF] Update LRU position
|
|
49
55
|
this._updateLRU(key);
|
|
50
|
-
this.stats.hits++;
|
|
51
56
|
|
|
52
57
|
return entry.value;
|
|
53
58
|
}
|
|
54
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Set value with optional TTL
|
|
62
|
+
* @param {string} key - Storage key
|
|
63
|
+
* @param {any} value - Value to store
|
|
64
|
+
* @param {number} ttl - Time to live in milliseconds
|
|
65
|
+
* @returns {Promise<void>}
|
|
66
|
+
*/
|
|
55
67
|
async set(key, value, ttl = this.ttl) {
|
|
56
|
-
//
|
|
68
|
+
// [PERF] Check if key exists for LRU update
|
|
57
69
|
if (this.store.has(key)) {
|
|
58
70
|
this._updateLRU(key);
|
|
59
71
|
} else {
|
|
60
|
-
//
|
|
72
|
+
// [PERF] Evict if at capacity
|
|
61
73
|
if (this.store.size >= this.maxSize) {
|
|
62
74
|
this._evict();
|
|
63
75
|
}
|
|
64
76
|
this.lru.push(key);
|
|
65
77
|
}
|
|
66
78
|
|
|
79
|
+
// [PERF] Calculate expiration only if TTL provided
|
|
67
80
|
const expires = ttl ? Date.now() + ttl : null;
|
|
68
81
|
|
|
82
|
+
// [PERF] Store entry with minimal metadata
|
|
69
83
|
this.store.set(key, {
|
|
70
84
|
value,
|
|
71
85
|
expires,
|
|
72
|
-
createdAt: Date.now()
|
|
73
|
-
accessedAt: Date.now()
|
|
86
|
+
createdAt: Date.now()
|
|
74
87
|
});
|
|
75
|
-
|
|
76
|
-
this.stats.sets++;
|
|
77
|
-
this.stats.size = this.store.size;
|
|
78
|
-
|
|
79
|
-
this.emit('set', { key, value });
|
|
80
88
|
}
|
|
81
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Delete key from store
|
|
92
|
+
* @param {string} key - Key to delete
|
|
93
|
+
* @returns {Promise<boolean>} - True if deleted, false if not found
|
|
94
|
+
*/
|
|
82
95
|
async delete(key) {
|
|
83
96
|
const deleted = this.store.delete(key);
|
|
84
97
|
if (deleted) {
|
|
85
98
|
this._removeFromLRU(key);
|
|
86
|
-
this.stats.deletes++;
|
|
87
|
-
this.stats.size = this.store.size;
|
|
88
|
-
this.emit('delete', { key });
|
|
89
99
|
}
|
|
90
100
|
return deleted;
|
|
91
101
|
}
|
|
92
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Clear all stored data
|
|
105
|
+
* @returns {Promise<void>}
|
|
106
|
+
*/
|
|
93
107
|
async clear() {
|
|
94
108
|
this.store.clear();
|
|
95
109
|
this.lru = [];
|
|
96
|
-
|
|
97
|
-
this.emit('clear');
|
|
110
|
+
// [PERF] No statistics reset needed
|
|
98
111
|
}
|
|
99
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Check if key exists and is not expired
|
|
115
|
+
* @param {string} key - Key to check
|
|
116
|
+
* @returns {Promise<boolean>} - True if exists and valid
|
|
117
|
+
*/
|
|
100
118
|
async has(key) {
|
|
101
119
|
const entry = this.store.get(key);
|
|
102
120
|
if (!entry) return false;
|
|
103
121
|
|
|
122
|
+
// [PERF] Check expiration
|
|
104
123
|
if (entry.expires && Date.now() > entry.expires) {
|
|
105
124
|
this.store.delete(key);
|
|
106
125
|
this._removeFromLRU(key);
|
|
@@ -111,35 +130,64 @@ class MemoryStore extends EventEmitter {
|
|
|
111
130
|
return true;
|
|
112
131
|
}
|
|
113
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Get all keys in store (excluding expired)
|
|
135
|
+
* @returns {Promise<string[]>} - Array of keys
|
|
136
|
+
*/
|
|
114
137
|
async keys() {
|
|
115
|
-
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
const validKeys = [];
|
|
140
|
+
|
|
141
|
+
// [PERF] Manual iteration avoids Array.from overhead
|
|
142
|
+
for (const [key, entry] of this.store.entries()) {
|
|
143
|
+
if (!entry.expires || now <= entry.expires) {
|
|
144
|
+
validKeys.push(key);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return validKeys;
|
|
116
149
|
}
|
|
117
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Get current store size (excluding expired entries)
|
|
153
|
+
* @returns {Promise<number>} - Number of valid entries
|
|
154
|
+
*/
|
|
118
155
|
async size() {
|
|
119
|
-
|
|
156
|
+
// [PERF] Count only non-expired entries
|
|
157
|
+
if (this.ttl === 0) {
|
|
158
|
+
return this.store.size; // No expiration, all entries valid
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const now = Date.now();
|
|
162
|
+
let count = 0;
|
|
163
|
+
|
|
164
|
+
for (const entry of this.store.values()) {
|
|
165
|
+
if (!entry.expires || now <= entry.expires) {
|
|
166
|
+
count++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return count;
|
|
120
171
|
}
|
|
121
172
|
|
|
122
173
|
/**
|
|
123
|
-
* Update LRU order
|
|
124
|
-
* @param {string} key
|
|
174
|
+
* Update LRU order - move accessed key to end
|
|
175
|
+
* @param {string} key - Accessed key
|
|
176
|
+
* @private
|
|
125
177
|
*/
|
|
126
178
|
_updateLRU(key) {
|
|
127
179
|
const index = this.lru.indexOf(key);
|
|
128
180
|
if (index > -1) {
|
|
181
|
+
// [PERF] Splice is faster than filter for single element removal
|
|
129
182
|
this.lru.splice(index, 1);
|
|
130
183
|
}
|
|
131
184
|
this.lru.push(key);
|
|
132
|
-
|
|
133
|
-
// Update accessed time
|
|
134
|
-
const entry = this.store.get(key);
|
|
135
|
-
if (entry) {
|
|
136
|
-
entry.accessedAt = Date.now();
|
|
137
|
-
}
|
|
138
185
|
}
|
|
139
186
|
|
|
140
187
|
/**
|
|
141
188
|
* Remove key from LRU list
|
|
142
|
-
* @param {string} key
|
|
189
|
+
* @param {string} key - Key to remove
|
|
190
|
+
* @private
|
|
143
191
|
*/
|
|
144
192
|
_removeFromLRU(key) {
|
|
145
193
|
const index = this.lru.indexOf(key);
|
|
@@ -149,44 +197,45 @@ class MemoryStore extends EventEmitter {
|
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
/**
|
|
152
|
-
* Evict least recently used item
|
|
200
|
+
* Evict least recently used item when at capacity
|
|
201
|
+
* @private
|
|
153
202
|
*/
|
|
154
203
|
_evict() {
|
|
155
204
|
if (this.lru.length === 0) return;
|
|
156
205
|
|
|
206
|
+
// [PERF] Shift is O(1) for array
|
|
157
207
|
const oldestKey = this.lru.shift();
|
|
158
208
|
this.store.delete(oldestKey);
|
|
159
|
-
|
|
160
|
-
this.emit('evict', { key: oldestKey });
|
|
209
|
+
// [PERF] No statistics update
|
|
161
210
|
}
|
|
162
211
|
|
|
163
212
|
/**
|
|
164
213
|
* Cleanup expired items
|
|
214
|
+
* @private
|
|
165
215
|
*/
|
|
166
216
|
_cleanup() {
|
|
167
217
|
const now = Date.now();
|
|
168
|
-
|
|
218
|
+
let deleted = false;
|
|
169
219
|
|
|
220
|
+
// [PERF] Iterate and collect expired keys
|
|
170
221
|
for (const [key, entry] of this.store.entries()) {
|
|
171
222
|
if (entry.expires && now > entry.expires) {
|
|
172
|
-
|
|
223
|
+
this.store.delete(key);
|
|
224
|
+
this._removeFromLRU(key);
|
|
225
|
+
deleted = true;
|
|
173
226
|
}
|
|
174
227
|
}
|
|
175
228
|
|
|
176
|
-
|
|
177
|
-
this.store.delete(key);
|
|
178
|
-
this._removeFromLRU(key);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
if (keysToDelete.length > 0) {
|
|
182
|
-
this.stats.size = this.store.size;
|
|
183
|
-
this.emit('cleanup', { count: keysToDelete.length });
|
|
184
|
-
}
|
|
229
|
+
// [PERF] No event emission or statistics update
|
|
185
230
|
}
|
|
186
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Destroy store and cleanup resources
|
|
234
|
+
*/
|
|
187
235
|
destroy() {
|
|
188
236
|
clearInterval(this.cleanupInterval);
|
|
189
|
-
this.clear();
|
|
237
|
+
this.store.clear();
|
|
238
|
+
this.lru = [];
|
|
190
239
|
}
|
|
191
240
|
}
|
|
192
241
|
|
|
@@ -196,8 +245,6 @@ class MemoryStore extends EventEmitter {
|
|
|
196
245
|
* @returns {MemoryStore} - Store instance
|
|
197
246
|
*/
|
|
198
247
|
function createAetherStore(options = {}) {
|
|
199
|
-
// In a full implementation, this would switch between Memory, Redis, etc.
|
|
200
|
-
// For now, we return the high-performance MemoryStore
|
|
201
248
|
return new MemoryStore(options);
|
|
202
249
|
}
|
|
203
250
|
|
|
@@ -1,17 +1,46 @@
|
|
|
1
|
-
|
|
2
1
|
/**
|
|
3
2
|
* @license MIT
|
|
4
3
|
* Copyright (c) 2026-present AetherFramework Contributors.
|
|
5
4
|
* SPDX-License-Identifier: MIT
|
|
6
|
-
* @module @aetherframework/middleware/middleware/compression
|
|
5
|
+
* @module @aetherframework/middleware/middleware/compression
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import zlib from "zlib";
|
|
10
9
|
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Safely converts headers to a plain object for logging or processing.
|
|
12
|
+
* This prevents issues where internal Header Map structures are displayed
|
|
13
|
+
* with numeric indices in consoles.
|
|
14
|
+
*
|
|
15
|
+
* @param {Object|Headers} headers - The headers object to normalize.
|
|
16
|
+
* @returns {Object} - A plain key-value object.
|
|
17
|
+
*/
|
|
18
|
+
function normalizeHeaders(headers) {
|
|
19
|
+
if (!headers) return {};
|
|
20
|
+
|
|
21
|
+
// If it's already a plain object, return a copy
|
|
22
|
+
if (typeof headers === 'object' && !Array.isArray(headers) && !(headers instanceof Map)) {
|
|
23
|
+
return { ...headers };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// If it's a Map or Headers object, convert to plain object
|
|
27
|
+
if (headers instanceof Map || (typeof headers.entries === 'function')) {
|
|
28
|
+
const obj = {};
|
|
29
|
+
for (const [key, value] of headers.entries()) {
|
|
30
|
+
obj[key.toLowerCase()] = value;
|
|
31
|
+
}
|
|
32
|
+
return obj;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fallback
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parses a comma-separated string of MIME types into an array.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} types - Comma-separated list of content types.
|
|
43
|
+
* @returns {Array<string>} - Array of trimmed content type strings.
|
|
15
44
|
*/
|
|
16
45
|
function parseCompressionTypes(types) {
|
|
17
46
|
if (!types || typeof types !== "string") {
|
|
@@ -35,10 +64,11 @@ function parseCompressionTypes(types) {
|
|
|
35
64
|
}
|
|
36
65
|
|
|
37
66
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* @param {
|
|
41
|
-
* @
|
|
67
|
+
* Checks if a given content type should be compressed based on the allowed list.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} contentType - The response content type.
|
|
70
|
+
* @param {Array<string>} compressibleTypes - List of compressible MIME types.
|
|
71
|
+
* @returns {boolean} - True if the content type is compressible.
|
|
42
72
|
*/
|
|
43
73
|
function shouldCompress(contentType, compressibleTypes) {
|
|
44
74
|
if (!contentType) return false;
|
|
@@ -53,9 +83,10 @@ function shouldCompress(contentType, compressibleTypes) {
|
|
|
53
83
|
}
|
|
54
84
|
|
|
55
85
|
/**
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* @
|
|
86
|
+
* Creates the compression middleware for AetherJS.
|
|
87
|
+
*
|
|
88
|
+
* @param {Object} options - Compression configuration options.
|
|
89
|
+
* @returns {Function} - The compression middleware function.
|
|
59
90
|
*/
|
|
60
91
|
function createCompressionMiddleware(options = {}) {
|
|
61
92
|
// Load configuration from environment variables
|
|
@@ -73,30 +104,33 @@ function createCompressionMiddleware(options = {}) {
|
|
|
73
104
|
types: process.env.COMPRESSION_TYPES,
|
|
74
105
|
};
|
|
75
106
|
|
|
76
|
-
//
|
|
107
|
+
// Define default configuration values
|
|
77
108
|
const defaults = {
|
|
78
|
-
|
|
109
|
+
// Only enable when explicitly set to "true"
|
|
110
|
+
enabled: envConfig.enabled === "true",
|
|
111
|
+
|
|
79
112
|
threshold: parseInt(envConfig.threshold) || 1024,
|
|
80
113
|
level: parseInt(envConfig.level) || zlib.constants.Z_DEFAULT_COMPRESSION,
|
|
81
114
|
memLevel: parseInt(envConfig.memLevel) || 8,
|
|
82
115
|
strategy: parseInt(envConfig.strategy) || zlib.constants.Z_DEFAULT_STRATEGY,
|
|
83
116
|
chunkSize: parseInt(envConfig.chunkSize) || 16 * 1024,
|
|
84
117
|
windowBits: parseInt(envConfig.windowBits) || 15,
|
|
85
|
-
|
|
118
|
+
|
|
119
|
+
// Apply same strict boolean checking for other options
|
|
120
|
+
gzip: envConfig.gzip === "true",
|
|
86
121
|
deflate: envConfig.deflate === "true",
|
|
87
|
-
brotli:
|
|
88
|
-
|
|
89
|
-
typeof zlib.createBrotliCompress === "function",
|
|
122
|
+
brotli: envConfig.brotli === "true" && typeof zlib.createBrotliCompress === "function",
|
|
123
|
+
|
|
90
124
|
types: parseCompressionTypes(envConfig.types),
|
|
91
125
|
filter: (contentType) =>
|
|
92
126
|
shouldCompress(contentType, parseCompressionTypes(envConfig.types)),
|
|
93
127
|
};
|
|
94
128
|
|
|
95
|
-
// Merge with provided options
|
|
129
|
+
// Merge default configuration with user-provided options
|
|
96
130
|
const config = { ...defaults, ...options };
|
|
97
131
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
132
|
+
// Configure zlib options for Gzip and Deflate
|
|
133
|
+
const zlibOptions = {
|
|
100
134
|
level: config.level,
|
|
101
135
|
memLevel: config.memLevel,
|
|
102
136
|
strategy: config.strategy,
|
|
@@ -104,20 +138,21 @@ function createCompressionMiddleware(options = {}) {
|
|
|
104
138
|
windowBits: config.windowBits,
|
|
105
139
|
};
|
|
106
140
|
|
|
107
|
-
|
|
141
|
+
// Configure Brotli specific parameters
|
|
108
142
|
const brotliOptions = {
|
|
109
143
|
params: {
|
|
110
|
-
[zlib.constants.BROTLI_PARAM_QUALITY]: config.level,
|
|
144
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: config.level === -1 ? 4 : config.level,
|
|
111
145
|
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
|
|
112
146
|
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: config.chunkSize,
|
|
113
147
|
},
|
|
114
148
|
};
|
|
115
149
|
|
|
116
150
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* @param {
|
|
120
|
-
* @
|
|
151
|
+
* Compresses a data buffer using the specified encoding algorithm.
|
|
152
|
+
*
|
|
153
|
+
* @param {Buffer} data - The raw data buffer to compress.
|
|
154
|
+
* @param {string} encoding - The compression algorithm ('gzip', 'deflate', or 'br').
|
|
155
|
+
* @returns {Promise<Buffer>} - A promise that resolves with the compressed buffer.
|
|
121
156
|
*/
|
|
122
157
|
async function compressData(data, encoding) {
|
|
123
158
|
return new Promise((resolve, reject) => {
|
|
@@ -125,21 +160,19 @@ function createCompressionMiddleware(options = {}) {
|
|
|
125
160
|
|
|
126
161
|
switch (encoding) {
|
|
127
162
|
case "gzip":
|
|
128
|
-
compressor = zlib.createGzip(
|
|
163
|
+
compressor = zlib.createGzip(zlibOptions);
|
|
129
164
|
break;
|
|
130
165
|
case "deflate":
|
|
131
|
-
compressor = zlib.createDeflate(
|
|
166
|
+
compressor = zlib.createDeflate(zlibOptions);
|
|
132
167
|
break;
|
|
133
168
|
case "br":
|
|
134
169
|
if (!config.brotli) {
|
|
135
|
-
reject(new Error("Brotli compression not supported"));
|
|
136
|
-
return;
|
|
170
|
+
return reject(new Error("Brotli compression is not supported in this environment"));
|
|
137
171
|
}
|
|
138
172
|
compressor = zlib.createBrotliCompress(brotliOptions);
|
|
139
173
|
break;
|
|
140
174
|
default:
|
|
141
|
-
reject(new Error(`Unsupported compression: ${encoding}`));
|
|
142
|
-
return;
|
|
175
|
+
return reject(new Error(`Unsupported compression encoding: ${encoding}`));
|
|
143
176
|
}
|
|
144
177
|
|
|
145
178
|
const chunks = [];
|
|
@@ -153,12 +186,12 @@ function createCompressionMiddleware(options = {}) {
|
|
|
153
186
|
}
|
|
154
187
|
|
|
155
188
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* @param {Object}
|
|
189
|
+
* The core middleware function.
|
|
190
|
+
*
|
|
191
|
+
* @param {Object} context - The AetherJS execution context.
|
|
192
|
+
* @param {Object|Function} signal - The signal object or next function.
|
|
159
193
|
*/
|
|
160
194
|
return async function compressionMiddleware(context, signal) {
|
|
161
|
-
// Safe invoker for compatible pipeline flow control
|
|
162
195
|
const invokeNext = async () => {
|
|
163
196
|
if (signal && typeof signal.next === "function") {
|
|
164
197
|
await signal.next();
|
|
@@ -167,78 +200,119 @@ function createCompressionMiddleware(options = {}) {
|
|
|
167
200
|
}
|
|
168
201
|
};
|
|
169
202
|
|
|
203
|
+
// Bypass middleware if compression is disabled
|
|
170
204
|
if (!config.enabled) {
|
|
171
205
|
return await invokeNext();
|
|
172
206
|
}
|
|
173
207
|
|
|
174
|
-
//
|
|
175
|
-
const
|
|
208
|
+
// Retrieve the underlying native Node.js HTTP response object
|
|
209
|
+
const res = context.res || context.rawRes;
|
|
210
|
+
|
|
211
|
+
// If native response object is not available, bypass compression
|
|
212
|
+
if (!res || typeof res.end !== "function") {
|
|
213
|
+
return await invokeNext();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Prevent multiple hooks on the same response object
|
|
217
|
+
if (res._compressionHooked) {
|
|
218
|
+
return await invokeNext();
|
|
219
|
+
}
|
|
220
|
+
res._compressionHooked = true;
|
|
176
221
|
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
if (this._terminated) return;
|
|
222
|
+
// Store the original res.end method
|
|
223
|
+
const originalEnd = res.end;
|
|
180
224
|
|
|
181
|
-
|
|
225
|
+
// Override res.end to intercept the response body
|
|
226
|
+
res.end = function (chunk, encoding, callback) {
|
|
227
|
+
// If headers are already sent, stream is ended, or no chunk is provided, bypass compression
|
|
228
|
+
if (res.writableEnded || res.headersSent || !chunk) {
|
|
229
|
+
return originalEnd.call(res, chunk, encoding, callback);
|
|
230
|
+
}
|
|
182
231
|
|
|
183
|
-
//
|
|
232
|
+
// Determine the response content type
|
|
184
233
|
let contentType = "";
|
|
185
|
-
if (typeof
|
|
186
|
-
contentType =
|
|
187
|
-
} else if (
|
|
188
|
-
contentType =
|
|
234
|
+
if (typeof res.getHeader === "function") {
|
|
235
|
+
contentType = res.getHeader("content-type") || "";
|
|
236
|
+
} else if (typeof context.getHeader === "function") {
|
|
237
|
+
contentType = context.getHeader("content-type") || "";
|
|
189
238
|
}
|
|
190
239
|
|
|
191
|
-
// Check if
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
(!Buffer.isBuffer(body) && typeof body !== "string") ||
|
|
195
|
-
Buffer.byteLength(body) < config.threshold ||
|
|
196
|
-
!config.filter(contentType)
|
|
197
|
-
) {
|
|
198
|
-
return originalize.call(this);
|
|
240
|
+
// Check if the content type is eligible for compression
|
|
241
|
+
if (!config.filter(contentType)) {
|
|
242
|
+
return originalEnd.call(res, chunk, encoding, callback);
|
|
199
243
|
}
|
|
200
244
|
|
|
201
|
-
//
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
: "") || "";
|
|
206
|
-
let encoding = null;
|
|
245
|
+
// Convert the chunk to a Buffer
|
|
246
|
+
const bodyBuffer = Buffer.isBuffer(chunk)
|
|
247
|
+
? chunk
|
|
248
|
+
: Buffer.from(chunk, typeof encoding === "string" ? encoding : "utf8");
|
|
207
249
|
|
|
208
|
-
//
|
|
209
|
-
if (
|
|
210
|
-
encoding
|
|
211
|
-
} else if (config.deflate && acceptEncoding.includes("deflate")) {
|
|
212
|
-
encoding = "deflate";
|
|
213
|
-
} else if (config.brotli && acceptEncoding.includes("br")) {
|
|
214
|
-
encoding = "br";
|
|
250
|
+
// Bypass compression if the body size is below the configured threshold
|
|
251
|
+
if (bodyBuffer.length < config.threshold) {
|
|
252
|
+
return originalEnd.call(res, chunk, encoding, callback);
|
|
215
253
|
}
|
|
216
254
|
|
|
217
|
-
|
|
218
|
-
|
|
255
|
+
// Extract accepted encodings from the incoming request headers
|
|
256
|
+
// FIX: Use normalizeHeaders to ensure we get a clean string, not an object structure
|
|
257
|
+
let acceptEncoding = "";
|
|
258
|
+
if (context.req && context.req.headers) {
|
|
259
|
+
// Ensure we access the header as a simple string
|
|
260
|
+
const rawHeaders = context.req.headers;
|
|
261
|
+
acceptEncoding = (rawHeaders['accept-encoding'] || rawHeaders['Accept-Encoding'] || "").toString();
|
|
262
|
+
} else if (typeof context.getHeader === "function") {
|
|
263
|
+
acceptEncoding = (context.getHeader("accept-encoding") || "").toString();
|
|
219
264
|
}
|
|
220
265
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
this._body = compressed;
|
|
230
|
-
if (typeof this.setHeader === "function") {
|
|
231
|
-
this.setHeader("content-encoding", encoding);
|
|
232
|
-
this.setHeader("vary", "Accept-Encoding");
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Call original finalize
|
|
236
|
-
return originalize.call(this);
|
|
237
|
-
} catch (error) {
|
|
238
|
-
// Compression failed, use original body
|
|
239
|
-
console.error("Compression error:", error);
|
|
240
|
-
return originalize.call(this);
|
|
266
|
+
// Determine the best compression algorithm
|
|
267
|
+
let chosenEncoding = null;
|
|
268
|
+
if (config.brotli && acceptEncoding.includes("br")) {
|
|
269
|
+
chosenEncoding = "br";
|
|
270
|
+
} else if (config.gzip && acceptEncoding.includes("gzip")) {
|
|
271
|
+
chosenEncoding = "gzip";
|
|
272
|
+
} else if (config.deflate && acceptEncoding.includes("deflate")) {
|
|
273
|
+
chosenEncoding = "deflate";
|
|
241
274
|
}
|
|
275
|
+
|
|
276
|
+
// If no supported encoding is accepted by the client, bypass compression
|
|
277
|
+
if (!chosenEncoding) {
|
|
278
|
+
return originalEnd.call(res, chunk, encoding, callback);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Perform asynchronous compression
|
|
282
|
+
compressData(bodyBuffer, chosenEncoding)
|
|
283
|
+
.then((compressed) => {
|
|
284
|
+
// Update response headers
|
|
285
|
+
res.setHeader("content-encoding", chosenEncoding);
|
|
286
|
+
|
|
287
|
+
// Append 'Accept-Encoding' to the Vary header
|
|
288
|
+
const varyHeader = res.getHeader("vary");
|
|
289
|
+
if (varyHeader) {
|
|
290
|
+
res.setHeader("vary", `${varyHeader}, Accept-Encoding`);
|
|
291
|
+
} else {
|
|
292
|
+
res.setHeader("vary", "Accept-Encoding");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Remove content-length as the compressed size is different
|
|
296
|
+
res.removeHeader("content-length");
|
|
297
|
+
|
|
298
|
+
// Send the compressed buffer
|
|
299
|
+
originalEnd.call(res, compressed, null, callback);
|
|
300
|
+
})
|
|
301
|
+
.catch((error) => {
|
|
302
|
+
// Fallback to original body if compression fails
|
|
303
|
+
// FIX: Do NOT log the error object directly. Log only the message.
|
|
304
|
+
// This prevents the console from trying to serialize complex objects
|
|
305
|
+
// which might result in the "numeric index" display you saw.
|
|
306
|
+
console.error("[Compression] Compression failed, falling back to original body.");
|
|
307
|
+
if (error && error.message) {
|
|
308
|
+
console.error("[Compression] Reason:", error.message);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Ensure we don't try to set headers again if they were partially sent
|
|
312
|
+
if (!res.headersSent) {
|
|
313
|
+
originalEnd.call(res, chunk, encoding, callback);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
242
316
|
};
|
|
243
317
|
|
|
244
318
|
await invokeNext();
|