pulse-js-framework 1.10.4 → 1.11.1
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 +11 -0
- package/cli/build.js +13 -3
- package/compiler/directives.js +356 -0
- package/compiler/lexer.js +18 -3
- package/compiler/parser/core.js +6 -0
- package/compiler/parser/view.js +2 -6
- package/compiler/preprocessor.js +43 -23
- package/compiler/sourcemap.js +3 -1
- package/compiler/transformer/actions.js +329 -0
- package/compiler/transformer/export.js +7 -0
- package/compiler/transformer/expressions.js +85 -33
- package/compiler/transformer/imports.js +3 -0
- package/compiler/transformer/index.js +2 -0
- package/compiler/transformer/store.js +1 -1
- package/compiler/transformer/style.js +45 -16
- package/compiler/transformer/view.js +23 -2
- package/loader/rollup-plugin-server-components.js +391 -0
- package/loader/vite-plugin-server-components.js +420 -0
- package/loader/webpack-loader-server-components.js +356 -0
- package/package.json +124 -82
- package/runtime/async.js +4 -0
- package/runtime/context.js +16 -3
- package/runtime/dom-adapter.js +5 -3
- package/runtime/dom-virtual-list.js +2 -1
- package/runtime/form.js +8 -3
- package/runtime/graphql/cache.js +1 -1
- package/runtime/graphql/client.js +22 -0
- package/runtime/graphql/hooks.js +12 -6
- package/runtime/graphql/subscriptions.js +2 -0
- package/runtime/hmr.js +6 -3
- package/runtime/http.js +1 -0
- package/runtime/i18n.js +2 -0
- package/runtime/lru-cache.js +3 -1
- package/runtime/native.js +46 -20
- package/runtime/pulse.js +3 -0
- package/runtime/router/core.js +5 -1
- package/runtime/router/index.js +17 -1
- package/runtime/router/psc-integration.js +301 -0
- package/runtime/security.js +58 -29
- package/runtime/server-components/actions-server.js +798 -0
- package/runtime/server-components/actions.js +389 -0
- package/runtime/server-components/client.js +447 -0
- package/runtime/server-components/error-sanitizer.js +438 -0
- package/runtime/server-components/index.js +275 -0
- package/runtime/server-components/security-csrf.js +593 -0
- package/runtime/server-components/security-errors.js +227 -0
- package/runtime/server-components/security-ratelimit.js +733 -0
- package/runtime/server-components/security-validation.js +467 -0
- package/runtime/server-components/security.js +598 -0
- package/runtime/server-components/serializer.js +617 -0
- package/runtime/server-components/server.js +382 -0
- package/runtime/server-components/types.js +383 -0
- package/runtime/server-components/utils/mutex.js +60 -0
- package/runtime/server-components/utils/path-sanitizer.js +109 -0
- package/runtime/ssr.js +2 -1
- package/runtime/store.js +19 -10
- package/runtime/utils.js +12 -128
- package/types/animation.d.ts +300 -0
- package/types/i18n.d.ts +283 -0
- package/types/persistence.d.ts +267 -0
- package/types/sse.d.ts +248 -0
- package/types/sw.d.ts +150 -0
- package/runtime/a11y.js.original +0 -1844
- package/runtime/graphql.js.original +0 -1326
- package/runtime/router.js.original +0 -1605
|
@@ -0,0 +1,733 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token Bucket Rate Limiter for Server Actions
|
|
3
|
+
*
|
|
4
|
+
* Provides flexible rate limiting with multiple strategies:
|
|
5
|
+
* - Per-action rate limiting (max calls per action)
|
|
6
|
+
* - Per-user rate limiting (max calls per user/IP)
|
|
7
|
+
* - Global rate limiting (max calls across all actions)
|
|
8
|
+
*
|
|
9
|
+
* Uses token bucket algorithm for smooth rate limiting with O(1) complexity.
|
|
10
|
+
* Supports distributed systems via pluggable storage backends (Memory, Redis).
|
|
11
|
+
*
|
|
12
|
+
* @module pulse-js-framework/runtime/server-components/security-ratelimit
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createMutex } from './utils/mutex.js';
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Constants
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Maximum time difference to prevent overflow (30 days in seconds)
|
|
23
|
+
*/
|
|
24
|
+
const MAX_TIME_DIFF = 86400 * 30;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Maximum token count to prevent overflow
|
|
28
|
+
*/
|
|
29
|
+
const MAX_TOKENS = 1000000;
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Token Bucket Implementation
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Token bucket for rate limiting with constant refill rate.
|
|
37
|
+
*
|
|
38
|
+
* The token bucket algorithm:
|
|
39
|
+
* 1. Bucket starts with max tokens
|
|
40
|
+
* 2. Each request consumes tokens
|
|
41
|
+
* 3. Tokens refill at constant rate over time
|
|
42
|
+
* 4. Request allowed if enough tokens available
|
|
43
|
+
*
|
|
44
|
+
* SECURITY: Uses mutex lock to prevent race conditions in concurrent environments.
|
|
45
|
+
* Includes overflow protection for time calculations and token counts.
|
|
46
|
+
*
|
|
47
|
+
* Time complexity: O(1) for all operations
|
|
48
|
+
* Space complexity: O(1) per bucket
|
|
49
|
+
*/
|
|
50
|
+
class TokenBucket {
|
|
51
|
+
#tokens;
|
|
52
|
+
#maxTokens;
|
|
53
|
+
#refillRate;
|
|
54
|
+
#lastRefill;
|
|
55
|
+
#mutex;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Create a token bucket
|
|
59
|
+
* @param {number} maxRequests - Maximum tokens in bucket
|
|
60
|
+
* @param {number} windowMs - Time window in milliseconds
|
|
61
|
+
* @param {number} [refillRate] - Tokens to refill per second (default: maxRequests / (windowMs / 1000))
|
|
62
|
+
*/
|
|
63
|
+
constructor(maxRequests, windowMs, refillRate = null) {
|
|
64
|
+
this.#maxTokens = maxRequests;
|
|
65
|
+
this.#tokens = maxRequests; // Start with full bucket
|
|
66
|
+
this.#refillRate = refillRate || (maxRequests / (windowMs / 1000));
|
|
67
|
+
this.#lastRefill = Date.now();
|
|
68
|
+
this.#mutex = createMutex();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Try to consume tokens from the bucket (async, thread-safe)
|
|
73
|
+
* @param {number} count - Number of tokens to consume
|
|
74
|
+
* @returns {Promise<{allowed: boolean, remaining: number, resetAt: number}>}
|
|
75
|
+
*/
|
|
76
|
+
async consume(count = 1) {
|
|
77
|
+
return await this.#mutex.lock(() => {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
this.#refill(now);
|
|
80
|
+
|
|
81
|
+
if (this.#tokens >= count) {
|
|
82
|
+
this.#tokens -= count;
|
|
83
|
+
return {
|
|
84
|
+
allowed: true,
|
|
85
|
+
remaining: Math.floor(this.#tokens),
|
|
86
|
+
resetAt: now + ((this.#maxTokens - this.#tokens) / this.#refillRate) * 1000
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Rate limited - calculate when enough tokens will be available
|
|
91
|
+
const tokensNeeded = count - this.#tokens;
|
|
92
|
+
const retryAfter = Math.ceil((tokensNeeded / this.#refillRate) * 1000);
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
allowed: false,
|
|
96
|
+
remaining: 0,
|
|
97
|
+
resetAt: now + retryAfter
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Refill tokens based on time elapsed with overflow protection
|
|
104
|
+
* @param {number} now - Current timestamp
|
|
105
|
+
* @private
|
|
106
|
+
*/
|
|
107
|
+
#refill(now) {
|
|
108
|
+
const timePassed = (now - this.#lastRefill) / 1000;
|
|
109
|
+
|
|
110
|
+
// SECURITY: Prevent overflow in time calculations (max 30 days)
|
|
111
|
+
const safeTimePassed = Math.min(timePassed, MAX_TIME_DIFF);
|
|
112
|
+
|
|
113
|
+
// Calculate tokens to add with overflow protection
|
|
114
|
+
const tokensToAdd = safeTimePassed * this.#refillRate;
|
|
115
|
+
const safeTokensToAdd = Math.min(tokensToAdd, MAX_TOKENS);
|
|
116
|
+
|
|
117
|
+
this.#tokens = Math.min(this.#maxTokens, this.#tokens + safeTokensToAdd);
|
|
118
|
+
this.#lastRefill = now;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reset the bucket to full capacity
|
|
123
|
+
*/
|
|
124
|
+
reset() {
|
|
125
|
+
this.#tokens = this.#maxTokens;
|
|
126
|
+
this.#lastRefill = Date.now();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Serialize bucket state for storage
|
|
131
|
+
* @returns {Object}
|
|
132
|
+
*/
|
|
133
|
+
toJSON() {
|
|
134
|
+
return {
|
|
135
|
+
tokens: this.#tokens,
|
|
136
|
+
maxTokens: this.#maxTokens,
|
|
137
|
+
refillRate: this.#refillRate,
|
|
138
|
+
lastRefill: this.#lastRefill
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Restore bucket from serialized state
|
|
144
|
+
* @param {Object} state - Serialized bucket state
|
|
145
|
+
* @returns {TokenBucket}
|
|
146
|
+
*/
|
|
147
|
+
static fromJSON(state) {
|
|
148
|
+
const bucket = new TokenBucket(state.maxTokens, 1000, state.refillRate);
|
|
149
|
+
bucket.#tokens = state.tokens;
|
|
150
|
+
bucket.#lastRefill = state.lastRefill;
|
|
151
|
+
bucket.#maxTokens = state.maxTokens;
|
|
152
|
+
bucket.#refillRate = state.refillRate;
|
|
153
|
+
return bucket;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// Rate Limit Store Interface
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Abstract rate limit storage interface.
|
|
163
|
+
* Implementations must provide get/set/delete/clear methods.
|
|
164
|
+
*/
|
|
165
|
+
export class RateLimitStore {
|
|
166
|
+
/**
|
|
167
|
+
* Get value for key
|
|
168
|
+
* @param {string} key - Storage key
|
|
169
|
+
* @returns {Promise<any>} Value or null
|
|
170
|
+
*/
|
|
171
|
+
async get(key) {
|
|
172
|
+
throw new Error('RateLimitStore.get() must be implemented');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Set value for key with optional TTL
|
|
177
|
+
* @param {string} key - Storage key
|
|
178
|
+
* @param {any} value - Value to store
|
|
179
|
+
* @param {number} [ttl] - Time to live in milliseconds
|
|
180
|
+
* @returns {Promise<void>}
|
|
181
|
+
*/
|
|
182
|
+
async set(key, value, ttl) {
|
|
183
|
+
throw new Error('RateLimitStore.set() must be implemented');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Delete key
|
|
188
|
+
* @param {string} key - Storage key
|
|
189
|
+
* @returns {Promise<void>}
|
|
190
|
+
*/
|
|
191
|
+
async delete(key) {
|
|
192
|
+
throw new Error('RateLimitStore.delete() must be implemented');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Clear all keys
|
|
197
|
+
* @returns {Promise<void>}
|
|
198
|
+
*/
|
|
199
|
+
async clear() {
|
|
200
|
+
throw new Error('RateLimitStore.clear() must be implemented');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// In-Memory Store
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* In-memory rate limit store with automatic TTL cleanup.
|
|
210
|
+
* Suitable for single-server deployments.
|
|
211
|
+
*
|
|
212
|
+
* Features:
|
|
213
|
+
* - Automatic expiration cleanup
|
|
214
|
+
* - Memory efficient (periodic cleanup)
|
|
215
|
+
* - O(1) get/set operations
|
|
216
|
+
*/
|
|
217
|
+
export class MemoryRateLimitStore extends RateLimitStore {
|
|
218
|
+
#store;
|
|
219
|
+
#cleanupInterval;
|
|
220
|
+
#cleanupTimer;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Create in-memory store
|
|
224
|
+
* @param {Object} [options] - Store options
|
|
225
|
+
* @param {number} [options.cleanupInterval=60000] - Cleanup interval in ms (default: 1 minute)
|
|
226
|
+
*/
|
|
227
|
+
constructor(options = {}) {
|
|
228
|
+
super();
|
|
229
|
+
this.#store = new Map(); // key → { value, expiresAt }
|
|
230
|
+
this.#cleanupInterval = options.cleanupInterval || 60000;
|
|
231
|
+
this.#startCleanup();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get value for key
|
|
236
|
+
* @param {string} key - Storage key
|
|
237
|
+
* @returns {Promise<any>} Value or null
|
|
238
|
+
*/
|
|
239
|
+
async get(key) {
|
|
240
|
+
const entry = this.#store.get(key);
|
|
241
|
+
|
|
242
|
+
if (!entry) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Check expiration
|
|
247
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
248
|
+
this.#store.delete(key);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return entry.value;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Set value for key with optional TTL
|
|
257
|
+
* @param {string} key - Storage key
|
|
258
|
+
* @param {any} value - Value to store
|
|
259
|
+
* @param {number} [ttl] - Time to live in milliseconds
|
|
260
|
+
* @returns {Promise<void>}
|
|
261
|
+
*/
|
|
262
|
+
async set(key, value, ttl) {
|
|
263
|
+
const entry = {
|
|
264
|
+
value,
|
|
265
|
+
expiresAt: ttl ? Date.now() + ttl : null
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
this.#store.set(key, entry);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Delete key
|
|
273
|
+
* @param {string} key - Storage key
|
|
274
|
+
* @returns {Promise<void>}
|
|
275
|
+
*/
|
|
276
|
+
async delete(key) {
|
|
277
|
+
this.#store.delete(key);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Clear all keys
|
|
282
|
+
* @returns {Promise<void>}
|
|
283
|
+
*/
|
|
284
|
+
async clear() {
|
|
285
|
+
this.#store.clear();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Start automatic cleanup timer
|
|
290
|
+
* @private
|
|
291
|
+
*/
|
|
292
|
+
#startCleanup() {
|
|
293
|
+
this.#cleanupTimer = setInterval(() => {
|
|
294
|
+
const now = Date.now();
|
|
295
|
+
for (const [key, entry] of this.#store.entries()) {
|
|
296
|
+
if (entry.expiresAt && now > entry.expiresAt) {
|
|
297
|
+
this.#store.delete(key);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}, this.#cleanupInterval);
|
|
301
|
+
|
|
302
|
+
// Don't keep process alive
|
|
303
|
+
if (this.#cleanupTimer.unref) {
|
|
304
|
+
this.#cleanupTimer.unref();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stop cleanup timer and clear all data
|
|
310
|
+
*/
|
|
311
|
+
dispose() {
|
|
312
|
+
if (this.#cleanupTimer) {
|
|
313
|
+
clearInterval(this.#cleanupTimer);
|
|
314
|
+
this.#cleanupTimer = null;
|
|
315
|
+
}
|
|
316
|
+
this.#store.clear();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get current store size
|
|
321
|
+
* @returns {number}
|
|
322
|
+
*/
|
|
323
|
+
get size() {
|
|
324
|
+
return this.#store.size;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// Redis Store
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Sanitize Redis key to prevent injection attacks
|
|
334
|
+
*
|
|
335
|
+
* Only allows alphanumeric characters, dash, underscore, and colon.
|
|
336
|
+
* This prevents Redis command injection via malicious keys.
|
|
337
|
+
*
|
|
338
|
+
* @param {string} key - Unsanitized key
|
|
339
|
+
* @returns {string} Sanitized key safe for Redis
|
|
340
|
+
* @private
|
|
341
|
+
*/
|
|
342
|
+
function sanitizeRedisKey(key) {
|
|
343
|
+
// Only allow alphanumeric, dash, underscore, colon
|
|
344
|
+
return String(key).replace(/[^a-zA-Z0-9:_-]/g, '_');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Redis-backed rate limit store for distributed systems.
|
|
349
|
+
* Supports multi-server deployments with shared rate limits.
|
|
350
|
+
*
|
|
351
|
+
* Requires a Redis client instance (ioredis, node-redis, etc.)
|
|
352
|
+
*
|
|
353
|
+
* Features:
|
|
354
|
+
* - Distributed rate limiting
|
|
355
|
+
* - Automatic TTL expiration
|
|
356
|
+
* - O(1) operations (Redis)
|
|
357
|
+
* - Redis injection protection (key sanitization)
|
|
358
|
+
*
|
|
359
|
+
* @example
|
|
360
|
+
* import { createClient } from 'redis';
|
|
361
|
+
* const redisClient = createClient({ url: 'redis://localhost:6379' });
|
|
362
|
+
* await redisClient.connect();
|
|
363
|
+
*
|
|
364
|
+
* const store = new RedisRateLimitStore(redisClient, {
|
|
365
|
+
* prefix: 'myapp:ratelimit:'
|
|
366
|
+
* });
|
|
367
|
+
*/
|
|
368
|
+
export class RedisRateLimitStore extends RateLimitStore {
|
|
369
|
+
#client;
|
|
370
|
+
#prefix;
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Create Redis store
|
|
374
|
+
* @param {Object} redisClient - Redis client instance
|
|
375
|
+
* @param {Object} [options] - Store options
|
|
376
|
+
* @param {string} [options.prefix='pulse:ratelimit:'] - Key prefix
|
|
377
|
+
*/
|
|
378
|
+
constructor(redisClient, options = {}) {
|
|
379
|
+
super();
|
|
380
|
+
|
|
381
|
+
if (!redisClient) {
|
|
382
|
+
throw new Error('RedisRateLimitStore requires a Redis client instance');
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
this.#client = redisClient;
|
|
386
|
+
this.#prefix = sanitizeRedisKey(options.prefix || 'pulse:ratelimit:');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get value for key
|
|
391
|
+
* @param {string} key - Storage key
|
|
392
|
+
* @returns {Promise<any>} Value or null
|
|
393
|
+
*/
|
|
394
|
+
async get(key) {
|
|
395
|
+
const safeKey = sanitizeRedisKey(key);
|
|
396
|
+
const data = await this.#client.get(`${this.#prefix}${safeKey}`);
|
|
397
|
+
return data ? JSON.parse(data) : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Set value for key with optional TTL
|
|
402
|
+
* @param {string} key - Storage key
|
|
403
|
+
* @param {any} value - Value to store
|
|
404
|
+
* @param {number} [ttl] - Time to live in milliseconds
|
|
405
|
+
* @returns {Promise<void>}
|
|
406
|
+
*/
|
|
407
|
+
async set(key, value, ttl) {
|
|
408
|
+
const safeKey = sanitizeRedisKey(key);
|
|
409
|
+
const serialized = JSON.stringify(value);
|
|
410
|
+
const redisKey = `${this.#prefix}${safeKey}`;
|
|
411
|
+
|
|
412
|
+
if (ttl) {
|
|
413
|
+
// Redis expects TTL in seconds
|
|
414
|
+
await this.#client.setEx(redisKey, Math.ceil(ttl / 1000), serialized);
|
|
415
|
+
} else {
|
|
416
|
+
await this.#client.set(redisKey, serialized);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Delete key
|
|
422
|
+
* @param {string} key - Storage key
|
|
423
|
+
* @returns {Promise<void>}
|
|
424
|
+
*/
|
|
425
|
+
async delete(key) {
|
|
426
|
+
const safeKey = sanitizeRedisKey(key);
|
|
427
|
+
await this.#client.del(`${this.#prefix}${safeKey}`);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Clear all keys with prefix
|
|
432
|
+
* @returns {Promise<void>}
|
|
433
|
+
*/
|
|
434
|
+
async clear() {
|
|
435
|
+
const keys = await this.#client.keys(`${this.#prefix}*`);
|
|
436
|
+
if (keys.length > 0) {
|
|
437
|
+
await this.#client.del(...keys);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ============================================================================
|
|
443
|
+
// Rate Limiter
|
|
444
|
+
// ============================================================================
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Multi-strategy rate limiter for Server Actions.
|
|
448
|
+
*
|
|
449
|
+
* Supports:
|
|
450
|
+
* - Per-action limits (different limits per action)
|
|
451
|
+
* - Per-user limits (by IP, user ID, or custom identifier)
|
|
452
|
+
* - Global limits (across all actions)
|
|
453
|
+
* - Trusted IP bypass
|
|
454
|
+
*
|
|
455
|
+
* @example
|
|
456
|
+
* const limiter = new RateLimiter({
|
|
457
|
+
* perAction: {
|
|
458
|
+
* 'createUser': { maxRequests: 5, windowMs: 60000 },
|
|
459
|
+
* 'default': { maxRequests: 20, windowMs: 60000 }
|
|
460
|
+
* },
|
|
461
|
+
* perUser: {
|
|
462
|
+
* maxRequests: 100,
|
|
463
|
+
* windowMs: 60000
|
|
464
|
+
* },
|
|
465
|
+
* global: {
|
|
466
|
+
* maxRequests: 10000,
|
|
467
|
+
* windowMs: 60000
|
|
468
|
+
* },
|
|
469
|
+
* trustedIPs: ['127.0.0.1', '::1']
|
|
470
|
+
* });
|
|
471
|
+
*/
|
|
472
|
+
export class RateLimiter {
|
|
473
|
+
#store;
|
|
474
|
+
#perActionLimits;
|
|
475
|
+
#perUserLimit;
|
|
476
|
+
#globalLimit;
|
|
477
|
+
#identify;
|
|
478
|
+
#trustedIPs;
|
|
479
|
+
#buckets; // In-memory bucket cache
|
|
480
|
+
#bucketLocks; // Mutex locks per bucket key
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Create rate limiter
|
|
484
|
+
* @param {Object} [options] - Limiter options
|
|
485
|
+
* @param {Object} [options.perAction] - Per-action limits
|
|
486
|
+
* @param {Object} [options.perUser] - Per-user limits
|
|
487
|
+
* @param {Object} [options.global] - Global limits
|
|
488
|
+
* @param {RateLimitStore} [options.store] - Storage backend
|
|
489
|
+
* @param {Function} [options.identify] - User identifier function
|
|
490
|
+
* @param {string[]} [options.trustedIPs] - Bypass rate limits for these IPs
|
|
491
|
+
*/
|
|
492
|
+
constructor(options = {}) {
|
|
493
|
+
this.#store = options.store || new MemoryRateLimitStore();
|
|
494
|
+
this.#perActionLimits = options.perAction || {};
|
|
495
|
+
this.#perUserLimit = options.perUser || null;
|
|
496
|
+
this.#globalLimit = options.global || null;
|
|
497
|
+
this.#identify = options.identify || ((ctx) => ctx.ip || 'anonymous');
|
|
498
|
+
this.#trustedIPs = new Set(options.trustedIPs || []);
|
|
499
|
+
this.#buckets = new Map(); // key → TokenBucket
|
|
500
|
+
this.#bucketLocks = new Map(); // key → Mutex
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Check if request is allowed
|
|
505
|
+
* @param {Object} context - Request context
|
|
506
|
+
* @param {string} [context.actionId] - Server Action ID
|
|
507
|
+
* @param {string} [context.ip] - Client IP address
|
|
508
|
+
* @param {string} [context.userId] - User ID
|
|
509
|
+
* @param {Object} [context.headers] - Request headers
|
|
510
|
+
* @returns {Promise<{allowed: boolean, reason?: string, retryAfter?: number, remaining?: number, resetAt?: number, limit?: number}>}
|
|
511
|
+
*/
|
|
512
|
+
async check(context) {
|
|
513
|
+
const { actionId, ip } = context;
|
|
514
|
+
|
|
515
|
+
// Bypass for trusted IPs
|
|
516
|
+
if (ip && this.#trustedIPs.has(ip)) {
|
|
517
|
+
return { allowed: true };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Check global limit first (broadest scope)
|
|
521
|
+
let globalResult = null;
|
|
522
|
+
if (this.#globalLimit) {
|
|
523
|
+
globalResult = await this.#checkBucket('global', this.#globalLimit);
|
|
524
|
+
if (!globalResult.allowed) {
|
|
525
|
+
return {
|
|
526
|
+
allowed: false,
|
|
527
|
+
reason: 'Global rate limit exceeded',
|
|
528
|
+
retryAfter: globalResult.retryAfter,
|
|
529
|
+
remaining: 0,
|
|
530
|
+
resetAt: globalResult.resetAt,
|
|
531
|
+
limit: this.#globalLimit.maxRequests
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Check per-user limit
|
|
537
|
+
if (this.#perUserLimit) {
|
|
538
|
+
const userId = this.#identify(context);
|
|
539
|
+
const userResult = await this.#checkBucket(`user:${userId}`, this.#perUserLimit);
|
|
540
|
+
if (!userResult.allowed) {
|
|
541
|
+
return {
|
|
542
|
+
allowed: false,
|
|
543
|
+
reason: 'User rate limit exceeded',
|
|
544
|
+
retryAfter: userResult.retryAfter,
|
|
545
|
+
remaining: 0,
|
|
546
|
+
resetAt: userResult.resetAt,
|
|
547
|
+
limit: this.#perUserLimit.maxRequests
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Check per-action limit
|
|
553
|
+
if (actionId && this.#perActionLimits) {
|
|
554
|
+
const actionLimit = this.#perActionLimits[actionId] || this.#perActionLimits.default;
|
|
555
|
+
if (actionLimit) {
|
|
556
|
+
const userId = this.#identify(context);
|
|
557
|
+
const actionResult = await this.#checkBucket(`action:${actionId}:${userId}`, actionLimit);
|
|
558
|
+
if (!actionResult.allowed) {
|
|
559
|
+
return {
|
|
560
|
+
allowed: false,
|
|
561
|
+
reason: `Action rate limit exceeded: ${actionId}`,
|
|
562
|
+
retryAfter: actionResult.retryAfter,
|
|
563
|
+
remaining: 0,
|
|
564
|
+
resetAt: actionResult.resetAt,
|
|
565
|
+
limit: actionLimit.maxRequests
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Return success with remaining tokens for most specific limit
|
|
570
|
+
return {
|
|
571
|
+
allowed: true,
|
|
572
|
+
remaining: actionResult.remaining,
|
|
573
|
+
resetAt: actionResult.resetAt,
|
|
574
|
+
limit: actionLimit.maxRequests
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// All checks passed - return info from most specific limit that was checked
|
|
580
|
+
if (globalResult) {
|
|
581
|
+
return {
|
|
582
|
+
allowed: true,
|
|
583
|
+
remaining: globalResult.remaining,
|
|
584
|
+
resetAt: globalResult.resetAt,
|
|
585
|
+
limit: this.#globalLimit.maxRequests
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return { allowed: true };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Check a specific bucket (thread-safe with per-bucket mutex)
|
|
594
|
+
* @param {string} key - Bucket key
|
|
595
|
+
* @param {Object} config - Limit configuration
|
|
596
|
+
* @returns {Promise<{allowed: boolean, remaining?: number, resetAt?: number, retryAfter?: number}>}
|
|
597
|
+
* @private
|
|
598
|
+
*/
|
|
599
|
+
async #checkBucket(key, config) {
|
|
600
|
+
// Get or create mutex for this bucket key (double-checked locking pattern)
|
|
601
|
+
// This ensures we never create multiple mutexes for the same key
|
|
602
|
+
let lock = this.#bucketLocks.get(key);
|
|
603
|
+
if (!lock) {
|
|
604
|
+
lock = createMutex();
|
|
605
|
+
this.#bucketLocks.set(key, lock);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// CRITICAL: Lock this bucket for the entire check-consume-save cycle
|
|
609
|
+
// This prevents race conditions where multiple concurrent requests
|
|
610
|
+
// read the same bucket state before any writes complete
|
|
611
|
+
return await lock.lock(async () => {
|
|
612
|
+
// Try to get bucket from memory cache
|
|
613
|
+
let bucket = this.#buckets.get(key);
|
|
614
|
+
|
|
615
|
+
if (!bucket) {
|
|
616
|
+
// Try to restore from storage
|
|
617
|
+
const stored = await this.#store.get(key);
|
|
618
|
+
if (stored) {
|
|
619
|
+
bucket = TokenBucket.fromJSON(stored);
|
|
620
|
+
} else {
|
|
621
|
+
// Create new bucket
|
|
622
|
+
bucket = new TokenBucket(config.maxRequests, config.windowMs, config.refillRate);
|
|
623
|
+
}
|
|
624
|
+
this.#buckets.set(key, bucket);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// Try to consume token (bucket has its own mutex, but we already have outer lock)
|
|
628
|
+
const result = await bucket.consume(1);
|
|
629
|
+
|
|
630
|
+
// Save updated bucket to storage
|
|
631
|
+
await this.#store.set(key, bucket.toJSON(), config.windowMs * 2);
|
|
632
|
+
|
|
633
|
+
if (result.allowed) {
|
|
634
|
+
return {
|
|
635
|
+
allowed: true,
|
|
636
|
+
remaining: result.remaining,
|
|
637
|
+
resetAt: result.resetAt
|
|
638
|
+
};
|
|
639
|
+
} else {
|
|
640
|
+
return {
|
|
641
|
+
allowed: false,
|
|
642
|
+
retryAfter: result.resetAt - Date.now(),
|
|
643
|
+
resetAt: result.resetAt
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Reset limits for a specific key
|
|
651
|
+
* @param {string} key - Bucket key (e.g., 'user:192.168.1.1', 'action:createUser:user123')
|
|
652
|
+
* @returns {Promise<void>}
|
|
653
|
+
*/
|
|
654
|
+
async reset(key) {
|
|
655
|
+
this.#buckets.delete(key);
|
|
656
|
+
await this.#store.delete(key);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Clear all limits
|
|
661
|
+
* @returns {Promise<void>}
|
|
662
|
+
*/
|
|
663
|
+
async clear() {
|
|
664
|
+
this.#buckets.clear();
|
|
665
|
+
await this.#store.clear();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Get statistics about current rate limits
|
|
670
|
+
* @returns {{activeBuckets: number, storeSize: number}}
|
|
671
|
+
*/
|
|
672
|
+
getStats() {
|
|
673
|
+
return {
|
|
674
|
+
activeBuckets: this.#buckets.size,
|
|
675
|
+
storeSize: this.#store.size || 0
|
|
676
|
+
};
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Dispose limiter and cleanup resources
|
|
681
|
+
*/
|
|
682
|
+
dispose() {
|
|
683
|
+
this.#buckets.clear();
|
|
684
|
+
if (this.#store.dispose) {
|
|
685
|
+
this.#store.dispose();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ============================================================================
|
|
691
|
+
// Middleware Factory
|
|
692
|
+
// ============================================================================
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Create rate limiting middleware for Server Actions
|
|
696
|
+
*
|
|
697
|
+
* @param {Object} [options] - Rate limiter options (see RateLimiter constructor)
|
|
698
|
+
* @returns {Function} Middleware function
|
|
699
|
+
*
|
|
700
|
+
* @example
|
|
701
|
+
* // Express/Connect style
|
|
702
|
+
* const rateLimitMiddleware = createRateLimitMiddleware({
|
|
703
|
+
* perAction: {
|
|
704
|
+
* 'createUser': { maxRequests: 5, windowMs: 60000 }
|
|
705
|
+
* },
|
|
706
|
+
* perUser: { maxRequests: 100, windowMs: 60000 }
|
|
707
|
+
* });
|
|
708
|
+
*
|
|
709
|
+
* // Returns function that accepts context and returns result
|
|
710
|
+
* const result = rateLimitMiddleware({ actionId: 'createUser', ip: '192.168.1.1' });
|
|
711
|
+
* if (!result.allowed) {
|
|
712
|
+
* // Handle rate limit
|
|
713
|
+
* }
|
|
714
|
+
*/
|
|
715
|
+
export function createRateLimitMiddleware(options = {}) {
|
|
716
|
+
const limiter = new RateLimiter(options);
|
|
717
|
+
|
|
718
|
+
return async (context) => {
|
|
719
|
+
return await limiter.check(context);
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ============================================================================
|
|
724
|
+
// Exports
|
|
725
|
+
// ============================================================================
|
|
726
|
+
|
|
727
|
+
export default {
|
|
728
|
+
RateLimiter,
|
|
729
|
+
RateLimitStore,
|
|
730
|
+
MemoryRateLimitStore,
|
|
731
|
+
RedisRateLimitStore,
|
|
732
|
+
createRateLimitMiddleware
|
|
733
|
+
};
|