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.
Files changed (65) hide show
  1. package/README.md +11 -0
  2. package/cli/build.js +13 -3
  3. package/compiler/directives.js +356 -0
  4. package/compiler/lexer.js +18 -3
  5. package/compiler/parser/core.js +6 -0
  6. package/compiler/parser/view.js +2 -6
  7. package/compiler/preprocessor.js +43 -23
  8. package/compiler/sourcemap.js +3 -1
  9. package/compiler/transformer/actions.js +329 -0
  10. package/compiler/transformer/export.js +7 -0
  11. package/compiler/transformer/expressions.js +85 -33
  12. package/compiler/transformer/imports.js +3 -0
  13. package/compiler/transformer/index.js +2 -0
  14. package/compiler/transformer/store.js +1 -1
  15. package/compiler/transformer/style.js +45 -16
  16. package/compiler/transformer/view.js +23 -2
  17. package/loader/rollup-plugin-server-components.js +391 -0
  18. package/loader/vite-plugin-server-components.js +420 -0
  19. package/loader/webpack-loader-server-components.js +356 -0
  20. package/package.json +124 -82
  21. package/runtime/async.js +4 -0
  22. package/runtime/context.js +16 -3
  23. package/runtime/dom-adapter.js +5 -3
  24. package/runtime/dom-virtual-list.js +2 -1
  25. package/runtime/form.js +8 -3
  26. package/runtime/graphql/cache.js +1 -1
  27. package/runtime/graphql/client.js +22 -0
  28. package/runtime/graphql/hooks.js +12 -6
  29. package/runtime/graphql/subscriptions.js +2 -0
  30. package/runtime/hmr.js +6 -3
  31. package/runtime/http.js +1 -0
  32. package/runtime/i18n.js +2 -0
  33. package/runtime/lru-cache.js +3 -1
  34. package/runtime/native.js +46 -20
  35. package/runtime/pulse.js +3 -0
  36. package/runtime/router/core.js +5 -1
  37. package/runtime/router/index.js +17 -1
  38. package/runtime/router/psc-integration.js +301 -0
  39. package/runtime/security.js +58 -29
  40. package/runtime/server-components/actions-server.js +798 -0
  41. package/runtime/server-components/actions.js +389 -0
  42. package/runtime/server-components/client.js +447 -0
  43. package/runtime/server-components/error-sanitizer.js +438 -0
  44. package/runtime/server-components/index.js +275 -0
  45. package/runtime/server-components/security-csrf.js +593 -0
  46. package/runtime/server-components/security-errors.js +227 -0
  47. package/runtime/server-components/security-ratelimit.js +733 -0
  48. package/runtime/server-components/security-validation.js +467 -0
  49. package/runtime/server-components/security.js +598 -0
  50. package/runtime/server-components/serializer.js +617 -0
  51. package/runtime/server-components/server.js +382 -0
  52. package/runtime/server-components/types.js +383 -0
  53. package/runtime/server-components/utils/mutex.js +60 -0
  54. package/runtime/server-components/utils/path-sanitizer.js +109 -0
  55. package/runtime/ssr.js +2 -1
  56. package/runtime/store.js +19 -10
  57. package/runtime/utils.js +12 -128
  58. package/types/animation.d.ts +300 -0
  59. package/types/i18n.d.ts +283 -0
  60. package/types/persistence.d.ts +267 -0
  61. package/types/sse.d.ts +248 -0
  62. package/types/sw.d.ts +150 -0
  63. package/runtime/a11y.js.original +0 -1844
  64. package/runtime/graphql.js.original +0 -1326
  65. 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
+ };