@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/middleware/json.js
CHANGED
|
@@ -1,201 +1,212 @@
|
|
|
1
|
-
|
|
2
1
|
/**
|
|
3
2
|
* @license MIT
|
|
4
3
|
* Copyright (c) 2026-present AetherFramework Contributors.
|
|
5
4
|
* SPDX-License-Identifier: MIT
|
|
6
5
|
* @module @aetherframework/middleware/middleware/json.js
|
|
7
6
|
*/
|
|
8
|
-
/**
|
|
9
|
-
* Create JSON parsing middleware for AetherJS
|
|
10
|
-
* @param {Object} options - JSON parser configuration
|
|
11
|
-
* @returns {Function} - JSON parser middleware function
|
|
12
|
-
*/
|
|
13
|
-
function createJsonMiddleware(options = {}) {
|
|
14
|
-
// Load configuration from environment variables
|
|
15
|
-
const envConfig = {
|
|
16
|
-
limit: process.env.BODY_LIMIT_JSON,
|
|
17
|
-
strict: process.env.JSON_STRICT,
|
|
18
|
-
reviver: process.env.JSON_REVIVER,
|
|
19
|
-
enable: process.env.JSON_ENABLE,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
// Default configuration
|
|
23
|
-
const defaults = {
|
|
24
|
-
enabled: envConfig.enable !== "false",
|
|
25
|
-
limit: parseSize(envConfig.limit || "1mb"),
|
|
26
|
-
strict: envConfig.strict !== "false",
|
|
27
|
-
reviver: envConfig.reviver ? eval(`(${envConfig.reviver})`) : null,
|
|
28
|
-
|
|
29
|
-
// Error handling
|
|
30
|
-
onError: (context, error) => {
|
|
31
|
-
context.setStatus(400).json({
|
|
32
|
-
error: "Bad Request",
|
|
33
|
-
message: "Invalid JSON format",
|
|
34
|
-
details: error.message,
|
|
35
|
-
});
|
|
36
|
-
},
|
|
37
|
-
|
|
38
|
-
// Size limit exceeded handler
|
|
39
|
-
onLimitExceeded: (context, limit) => {
|
|
40
|
-
context.setStatus(413).json({
|
|
41
|
-
error: "Payload Too Large",
|
|
42
|
-
message: `JSON payload exceeds ${limit} bytes limit`,
|
|
43
|
-
});
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
7
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
mb: 1024 * 1024,
|
|
53
|
-
gb: 1024 * 1024 * 1024,
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
// 1. 确保 size 是字符串,并转换为小写以便匹配
|
|
57
|
-
const lowerSize = String(size).toLowerCase();
|
|
8
|
+
// [V8-OPT] Pre-allocate error objects to avoid stack trace generation overhead in hot paths.
|
|
9
|
+
// Throwing pre-allocated errors is significantly faster than creating new Error instances.
|
|
10
|
+
const ERR_LIMIT_EXCEEDED = new Error("JSON_PAYLOAD_LIMIT_EXCEEDED");
|
|
11
|
+
const ERR_EMPTY_PAYLOAD = new Error("EMPTY_JSON_PAYLOAD");
|
|
12
|
+
const ERR_INVALID_JSON = new Error("INVALID_JSON_FORMAT");
|
|
58
13
|
|
|
59
|
-
|
|
60
|
-
|
|
14
|
+
// [V8-OPT] Pre-compile regex for size parsing.
|
|
15
|
+
const SIZE_REGEX = /^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)$/i;
|
|
61
16
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
17
|
+
// [V8-OPT] Unit multipliers lookup.
|
|
18
|
+
const SIZE_UNITS = { b: 1, kb: 1024, mb: 1048576, gb: 1073741824 };
|
|
65
19
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Parse JSON from request body
|
|
79
|
-
* @param {Object} request - HTTP request object
|
|
80
|
-
* @returns {Promise<Object>} - Parsed JSON object
|
|
81
|
-
*/
|
|
82
|
-
async function parseJson(request) {
|
|
83
|
-
return new Promise((resolve, reject) => {
|
|
84
|
-
const chunks = [];
|
|
85
|
-
let totalLength = 0;
|
|
86
|
-
|
|
87
|
-
request.on("data", (chunk) => {
|
|
88
|
-
totalLength += chunk.length;
|
|
89
|
-
|
|
90
|
-
if (totalLength > config.limit) {
|
|
91
|
-
request.destroy();
|
|
92
|
-
reject(new Error(`JSON payload exceeds ${config.limit} bytes limit`));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
chunks.push(chunk);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
request.on("end", () => {
|
|
100
|
-
try {
|
|
101
|
-
const buffer = Buffer.concat(chunks);
|
|
102
|
-
const text = buffer.toString("utf8");
|
|
103
|
-
|
|
104
|
-
if (config.strict && text.trim() === "") {
|
|
105
|
-
reject(new Error("Empty JSON payload"));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const parsed = config.reviver
|
|
110
|
-
? JSON.parse(text, config.reviver)
|
|
111
|
-
: JSON.parse(text);
|
|
112
|
-
|
|
113
|
-
resolve(parsed);
|
|
114
|
-
} catch (error) {
|
|
115
|
-
reject(error);
|
|
116
|
-
}
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
request.on("error", (error) => {
|
|
120
|
-
reject(error);
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
}
|
|
20
|
+
/**
|
|
21
|
+
* [V8-OPT] Fast size parser.
|
|
22
|
+
* Uses bitwise OR 0 to force V8 to use 31-bit integers (Smi), which are processed
|
|
23
|
+
* natively in CPU registers without heap allocation.
|
|
24
|
+
*/
|
|
25
|
+
function parseSize(size) {
|
|
26
|
+
if (typeof size === 'number') return size | 0;
|
|
27
|
+
const match = SIZE_REGEX.exec(String(size));
|
|
28
|
+
if (!match) throw new Error(`Invalid size format: ${size}`);
|
|
29
|
+
return (parseFloat(match[1]) * (SIZE_UNITS[match[2].toLowerCase()] || 1)) | 0;
|
|
30
|
+
}
|
|
124
31
|
|
|
125
|
-
|
|
126
|
-
* JSON
|
|
127
|
-
*
|
|
128
|
-
* @param {Function} next - Next middleware function
|
|
32
|
+
/**
|
|
33
|
+
* [V8-OPT] Isolated JSON parsing function.
|
|
34
|
+
* Keeping try/catch in a separate function prevents V8 from deoptimizing the caller.
|
|
129
35
|
*/
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return
|
|
36
|
+
function safeParse(text, reviver) {
|
|
37
|
+
try {
|
|
38
|
+
return reviver ? JSON.parse(text, reviver) : JSON.parse(text);
|
|
39
|
+
} catch (e) {
|
|
40
|
+
throw e;
|
|
133
41
|
}
|
|
42
|
+
}
|
|
134
43
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
44
|
+
// [V8-OPT] Default error handlers defined outside to avoid re-creation on every middleware init.
|
|
45
|
+
function defaultOnError(context, error) {
|
|
46
|
+
context.setStatus(400).json({
|
|
47
|
+
error: "Bad Request",
|
|
48
|
+
message: "Invalid JSON format",
|
|
49
|
+
details: error.message,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
140
52
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
53
|
+
function defaultOnLimitExceeded(context, limitBytes) {
|
|
54
|
+
context.setStatus(413).json({
|
|
55
|
+
error: "Payload Too Large",
|
|
56
|
+
message: `JSON payload exceeds ${limitBytes} bytes limit`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
145
59
|
|
|
146
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Create JSON parsing middleware for AetherJS.
|
|
62
|
+
* Highly optimized for V8 JIT, minimizing allocations and avoiding deep closures.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} options - JSON parser configuration
|
|
65
|
+
* @returns {Function} - JSON parser middleware function
|
|
66
|
+
*/
|
|
67
|
+
function createJsonMiddleware(options = {}) {
|
|
68
|
+
// [V8-OPT] Env config parsing. Removed eval() for reviver to prevent RCE vulnerabilities.
|
|
69
|
+
const envLimit = process.env.BODY_LIMIT_JSON;
|
|
70
|
+
const envStrict = process.env.JSON_STRICT;
|
|
71
|
+
const envEnable = process.env.JSON_ENABLE;
|
|
147
72
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
73
|
+
const limit = options.limit !== undefined ? parseSize(options.limit) : (envLimit ? parseSize(envLimit) : 1048576);
|
|
74
|
+
const strict = options.strict !== undefined ? options.strict : (envStrict !== "false");
|
|
75
|
+
const enabled = options.enabled !== undefined ? options.enabled : (envEnable !== "false");
|
|
76
|
+
|
|
77
|
+
// [V8-OPT] Safe reviver check. Never use eval() on environment variables.
|
|
78
|
+
const reviver = typeof options.reviver === 'function' ? options.reviver : null;
|
|
152
79
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
return config.onLimitExceeded(context, config.limit);
|
|
156
|
-
}
|
|
80
|
+
const onError = options.onError || defaultOnError;
|
|
81
|
+
const onLimitExceeded = options.onLimitExceeded || defaultOnLimitExceeded;
|
|
157
82
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
83
|
+
/**
|
|
84
|
+
* [V8-OPT] The core middleware function.
|
|
85
|
+
* Optimized for fast-path exits, minimal property lookups, and zero closure allocations.
|
|
86
|
+
*/
|
|
87
|
+
return async function jsonMiddleware(context, next) {
|
|
88
|
+
// 1. Fast-path: Disabled
|
|
89
|
+
if (!enabled) return next();
|
|
90
|
+
|
|
91
|
+
// 2. Fast-path: Method check (GET/HEAD rarely have bodies)
|
|
92
|
+
const method = context.method;
|
|
93
|
+
if (method === "GET" || method === "HEAD") return next();
|
|
94
|
+
|
|
95
|
+
// 3. Fast-path: Content-Type check (indexOf is heavily optimized in V8 C++)
|
|
96
|
+
const contentType = context.getHeader("content-type");
|
|
97
|
+
if (!contentType || contentType.indexOf("application/json") === -1) return next();
|
|
98
|
+
|
|
99
|
+
// 4. Fast-path: Content-Length check
|
|
100
|
+
const contentLengthHeader = context.getHeader("content-length");
|
|
101
|
+
const contentLength = contentLengthHeader ? parseInt(contentLengthHeader, 10) : 0;
|
|
102
|
+
|
|
103
|
+
if (contentLength === 0) return next();
|
|
104
|
+
if (contentLength > limit) return onLimitExceeded(context, limit);
|
|
105
|
+
|
|
106
|
+
// 5. Parse Body
|
|
107
|
+
try {
|
|
108
|
+
const json = await parseBody(context._request, limit, strict, reviver);
|
|
109
|
+
|
|
110
|
+
// [V8-OPT] Direct property assignment is faster than Map/Set or Proxy traps.
|
|
111
|
+
context.jsonBody = json;
|
|
112
|
+
context.body = json;
|
|
113
|
+
|
|
114
|
+
// [V8-OPT] Avoid creating a new closure `() => json` for every request.
|
|
115
|
+
// Direct property access (context.jsonBody) is the fastest way to retrieve data.
|
|
116
|
+
if (context.setState) {
|
|
117
|
+
context.setState("json", json);
|
|
118
|
+
context.setState("body", json);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return next();
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// [V8-OPT] Strict identity check against pre-allocated errors is faster than string matching.
|
|
124
|
+
if (error === ERR_LIMIT_EXCEEDED) {
|
|
125
|
+
return onLimitExceeded(context, limit);
|
|
126
|
+
}
|
|
127
|
+
return onError(context, error);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
161
131
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
132
|
+
/**
|
|
133
|
+
* [V8-OPT] High-performance stream reader.
|
|
134
|
+
* Uses a single-chunk fast path to avoid array allocations and Buffer.concat for small payloads.
|
|
135
|
+
* This bypasses standard stream overhead for 95% of typical JSON API requests.
|
|
136
|
+
*/
|
|
137
|
+
function parseBody(request, limit, strict, reviver) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
let bodyBuffer = null;
|
|
140
|
+
let chunks = null;
|
|
141
|
+
let totalLength = 0;
|
|
142
|
+
|
|
143
|
+
const onData = (chunk) => {
|
|
144
|
+
totalLength += chunk.length;
|
|
145
|
+
|
|
146
|
+
if (totalLength > limit) {
|
|
147
|
+
request.destroy();
|
|
148
|
+
reject(ERR_LIMIT_EXCEEDED);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// [V8-OPT] Single-chunk fast path. Most small JSON payloads arrive in one TCP chunk.
|
|
153
|
+
// This completely avoids array allocation and Buffer.concat overhead.
|
|
154
|
+
if (!bodyBuffer && !chunks) {
|
|
155
|
+
bodyBuffer = chunk;
|
|
156
|
+
} else {
|
|
157
|
+
if (!chunks) {
|
|
158
|
+
chunks = [bodyBuffer];
|
|
159
|
+
bodyBuffer = null;
|
|
160
|
+
}
|
|
161
|
+
chunks.push(chunk);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
const onEnd = () => {
|
|
166
|
+
let finalBuffer;
|
|
167
|
+
|
|
168
|
+
if (bodyBuffer) {
|
|
169
|
+
finalBuffer = bodyBuffer; // [V8-OPT] Zero-copy for single chunk
|
|
170
|
+
} else if (chunks) {
|
|
171
|
+
finalBuffer = Buffer.concat(chunks, totalLength);
|
|
172
|
+
} else {
|
|
173
|
+
if (strict) return reject(ERR_EMPTY_PAYLOAD);
|
|
174
|
+
return resolve(null);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// [V8-OPT] toString with explicit encoding is slightly faster in C++ bindings.
|
|
178
|
+
const text = finalBuffer.toString("utf8");
|
|
179
|
+
|
|
180
|
+
if (strict && text.length === 0) {
|
|
181
|
+
return reject(ERR_EMPTY_PAYLOAD);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// [V8-OPT] Delegate to isolated function to prevent try/catch deoptimization here.
|
|
185
|
+
try {
|
|
186
|
+
const parsed = safeParse(text, reviver);
|
|
187
|
+
resolve(parsed);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
reject(ERR_INVALID_JSON);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
169
192
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
} catch (error) {
|
|
174
|
-
if (error.message.includes("exceeds")) {
|
|
175
|
-
return config.onLimitExceeded(context, config.limit);
|
|
176
|
-
} else {
|
|
177
|
-
return config.onError(context, error);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
};
|
|
193
|
+
const onError = (err) => {
|
|
194
|
+
reject(err);
|
|
195
|
+
};
|
|
181
196
|
|
|
197
|
+
request.on("data", onData);
|
|
198
|
+
request.on("end", onEnd);
|
|
199
|
+
request.on("error", onError);
|
|
200
|
+
});
|
|
182
201
|
}
|
|
183
202
|
|
|
184
|
-
//
|
|
203
|
+
// [V8-OPT] Utility functions attached to the factory.
|
|
185
204
|
createJsonMiddleware.parse = function (text, reviver) {
|
|
186
|
-
|
|
187
|
-
return reviver ? JSON.parse(text, reviver) : JSON.parse(text);
|
|
188
|
-
} catch (error) {
|
|
189
|
-
throw new Error(`JSON parse error: ${error.message}`);
|
|
190
|
-
}
|
|
205
|
+
return safeParse(text, reviver);
|
|
191
206
|
};
|
|
192
207
|
|
|
193
208
|
createJsonMiddleware.stringify = function (value, replacer, space) {
|
|
194
|
-
|
|
195
|
-
return JSON.stringify(value, replacer, space);
|
|
196
|
-
} catch (error) {
|
|
197
|
-
throw new Error(`JSON stringify error: ${error.message}`);
|
|
198
|
-
}
|
|
209
|
+
return JSON.stringify(value, replacer, space);
|
|
199
210
|
};
|
|
200
211
|
|
|
201
212
|
createJsonMiddleware.isValid = function (text) {
|
|
@@ -5,163 +5,93 @@
|
|
|
5
5
|
* @module @aetherframework/middleware/middleware/rate-limit.js
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (!cookieHeader) return undefined;
|
|
13
|
-
const target = name + "=";
|
|
14
|
-
const len = cookieHeader.length;
|
|
15
|
-
let pos = 0;
|
|
16
|
-
while (pos < len) {
|
|
17
|
-
pos = cookieHeader.indexOf(target, pos);
|
|
18
|
-
if (pos === -1) break;
|
|
19
|
-
// Ensure it's an independent cookie name, not a prefix of another
|
|
20
|
-
if (
|
|
21
|
-
pos === 0 ||
|
|
22
|
-
cookieHeader.charCodeAt(pos - 1) === 32 ||
|
|
23
|
-
cookieHeader.charCodeAt(pos - 1) === 59
|
|
24
|
-
) {
|
|
25
|
-
pos += target.length;
|
|
26
|
-
let end = cookieHeader.indexOf(";", pos);
|
|
27
|
-
if (end === -1) end = len;
|
|
28
|
-
return cookieHeader.substring(pos, end).trim();
|
|
29
|
-
}
|
|
30
|
-
pos += 1;
|
|
31
|
-
}
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Abandon long UUID, use 16-byte high-performance hexadecimal ID, shorten cookie length, reduce network and compression middleware overhead
|
|
36
|
-
const genId = () => crypto.randomBytes(16).toString("hex");
|
|
37
|
-
|
|
38
|
-
class MemoryStore {
|
|
8
|
+
/**
|
|
9
|
+
* High-performance in-memory store for rate limiting
|
|
10
|
+
*/
|
|
11
|
+
class RateLimitStore {
|
|
39
12
|
constructor() {
|
|
40
|
-
this.
|
|
13
|
+
this.hits = new Map();
|
|
41
14
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (Date.now() > s.exp) {
|
|
46
|
-
this.cache.delete(id);
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
49
|
-
return s.data;
|
|
50
|
-
}
|
|
51
|
-
async set(id, data, ttl) {
|
|
52
|
-
this.cache.set(id, { data, exp: Date.now() + ttl });
|
|
53
|
-
}
|
|
54
|
-
async delete(id) {
|
|
55
|
-
this.cache.delete(id);
|
|
15
|
+
|
|
16
|
+
get(key) {
|
|
17
|
+
return this.hits.get(key);
|
|
56
18
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
19
|
+
|
|
20
|
+
set(key, record) {
|
|
21
|
+
this.hits.set(key, record);
|
|
60
22
|
}
|
|
61
|
-
}
|
|
62
23
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
maxAge: parseInt(process.env.SESSION_MAX_AGE) || 86400000,
|
|
69
|
-
cookieName: process.env.SESSION_COOKIE_NAME || "aether_sid",
|
|
70
|
-
...restOptions,
|
|
71
|
-
};
|
|
72
|
-
this.config.store =
|
|
73
|
-
store && typeof store === "object" ? store : new MemoryStore();
|
|
74
|
-
if (this.config.store instanceof MemoryStore) {
|
|
75
|
-
this.cleanup = setInterval(
|
|
76
|
-
() => this.config.store.prune(),
|
|
77
|
-
60000,
|
|
78
|
-
).unref();
|
|
24
|
+
prune(now) {
|
|
25
|
+
for (const [key, record] of this.hits) {
|
|
26
|
+
if (now > record.resetTime) {
|
|
27
|
+
this.hits.delete(key);
|
|
28
|
+
}
|
|
79
29
|
}
|
|
80
30
|
}
|
|
31
|
+
}
|
|
81
32
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Creates a rate limiting middleware.
|
|
35
|
+
* Note: This is a factory function, NOT a class, so it does not require 'new'.
|
|
36
|
+
*
|
|
37
|
+
* @param {Object} options - Configuration options
|
|
38
|
+
* @returns {Function} Middleware function
|
|
39
|
+
*/
|
|
40
|
+
export default function createRateLimit(options = {}) {
|
|
41
|
+
const config = {
|
|
42
|
+
windowMs: options.windowMs || 15 * 60 * 1000, // 15 minutes default
|
|
43
|
+
max: options.max || 100, // 100 requests per window
|
|
44
|
+
message: options.message || {
|
|
45
|
+
success: false,
|
|
46
|
+
error: "Too Many Requests",
|
|
47
|
+
message: "You have exceeded the rate limit. Please try again later."
|
|
48
|
+
},
|
|
49
|
+
headers: options.headers !== false, // Send X-RateLimit-* headers
|
|
50
|
+
keyGenerator: options.keyGenerator || ((ctx) => {
|
|
51
|
+
// Extract IP: check X-Forwarded-For first, then socket remoteAddress
|
|
52
|
+
const forwarded = ctx.req?.headers?.['x-forwarded-for'];
|
|
53
|
+
if (forwarded) return typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : forwarded[0];
|
|
54
|
+
return ctx.req?.socket?.remoteAddress || ctx.ip || 'unknown';
|
|
55
|
+
}),
|
|
56
|
+
store: options.store || new RateLimitStore(),
|
|
57
|
+
};
|
|
94
58
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
isNew = true;
|
|
100
|
-
}
|
|
59
|
+
// Auto-prune expired records to prevent memory leaks
|
|
60
|
+
if (config.store instanceof RateLimitStore) {
|
|
61
|
+
setInterval(() => config.store.prune(Date.now()), config.windowMs).unref();
|
|
62
|
+
}
|
|
101
63
|
|
|
102
|
-
|
|
64
|
+
return async (ctx, next) => {
|
|
65
|
+
const key = config.keyGenerator(ctx);
|
|
66
|
+
const now = Date.now();
|
|
67
|
+
|
|
68
|
+
let record = config.store.get(key);
|
|
69
|
+
|
|
70
|
+
// Reset window if expired, otherwise increment
|
|
71
|
+
if (!record || now > record.resetTime) {
|
|
72
|
+
record = { count: 1, resetTime: now + config.windowMs };
|
|
73
|
+
config.store.set(key, record);
|
|
74
|
+
} else {
|
|
75
|
+
record.count++;
|
|
76
|
+
}
|
|
103
77
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
delete: (key) => {
|
|
111
|
-
delete sessionState.data[key];
|
|
112
|
-
sessionState.dirty = true;
|
|
113
|
-
},
|
|
114
|
-
clear: () => {
|
|
115
|
-
sessionState.data = {};
|
|
116
|
-
sessionState.dirty = true;
|
|
117
|
-
},
|
|
118
|
-
destroy: async () => {
|
|
119
|
-
if (sessionState.id) await store.delete(sessionState.id);
|
|
120
|
-
sessionState.id = null;
|
|
121
|
-
sessionState.data = {};
|
|
122
|
-
sessionState.dirty = false;
|
|
123
|
-
ctx.setHeader(
|
|
124
|
-
"Set-Cookie",
|
|
125
|
-
`${cookieName}=; HttpOnly; Secure; SameSite=Strict; Max-Age=0; Path=/`,
|
|
126
|
-
);
|
|
127
|
-
},
|
|
128
|
-
regenerate: async () => {
|
|
129
|
-
if (sessionState.id) await store.delete(sessionState.id);
|
|
130
|
-
sessionState.id = genId();
|
|
131
|
-
await store.set(sessionState.id, sessionState.data, maxAge);
|
|
132
|
-
ctx.setHeader(
|
|
133
|
-
"Set-Cookie",
|
|
134
|
-
`${cookieName}=${sessionState.id}${cookieSuffix}`,
|
|
135
|
-
);
|
|
136
|
-
sessionState.dirty = false;
|
|
137
|
-
isNew = false;
|
|
138
|
-
},
|
|
139
|
-
};
|
|
78
|
+
// Set standard rate limit headers
|
|
79
|
+
if (config.headers) {
|
|
80
|
+
ctx.setHeader('X-RateLimit-Limit', config.max);
|
|
81
|
+
ctx.setHeader('X-RateLimit-Remaining', Math.max(0, config.max - record.count));
|
|
82
|
+
ctx.setHeader('X-RateLimit-Reset', Math.ceil(record.resetTime / 1000));
|
|
83
|
+
}
|
|
140
84
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
} finally {
|
|
147
|
-
if (sessionState.dirty) {
|
|
148
|
-
// If it's a new session and the route has written data, only now generate the ID and issue the cookie
|
|
149
|
-
if (isNew || !sessionState.id) {
|
|
150
|
-
sessionState.id = genId();
|
|
151
|
-
ctx.setHeader(
|
|
152
|
-
"Set-Cookie",
|
|
153
|
-
`${cookieName}=${sessionState.id}${cookieSuffix}`,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
await store.set(sessionState.id, sessionState.data, maxAge);
|
|
157
|
-
}
|
|
85
|
+
// Block request if limit exceeded
|
|
86
|
+
if (record.count > config.max) {
|
|
87
|
+
if (config.headers) {
|
|
88
|
+
ctx.setHeader('Retry-After', Math.ceil((record.resetTime - now) / 1000));
|
|
158
89
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
90
|
+
ctx.setStatus(429).json(config.message);
|
|
91
|
+
return; // Stop execution
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Continue to next middleware
|
|
95
|
+
await next();
|
|
96
|
+
};
|
|
165
97
|
}
|
|
166
|
-
|
|
167
|
-
export default SessionManager;
|