webspresso 0.0.104 → 0.0.106

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 (233) hide show
  1. package/.agents/AGENTS.md +21 -2
  2. package/.agents/ARCHITECTURE.md +23 -0
  3. package/.agents/CACHE.md +147 -0
  4. package/.agents/CLI.md +220 -8
  5. package/.agents/EARLY_HINTS.md +120 -0
  6. package/.agents/FLAGS.md +118 -0
  7. package/.agents/HEALTH.md +118 -0
  8. package/.agents/LIMITS.md +109 -0
  9. package/.agents/LOGGING.md +93 -0
  10. package/.agents/METRICS.md +127 -0
  11. package/.agents/MIGRATION_JOBS.md +95 -0
  12. package/.agents/ORM.md +155 -1
  13. package/.agents/PLUGINS.md +38 -3
  14. package/.agents/QUEUE.md +42 -0
  15. package/.agents/RATE_LIMIT.md +147 -0
  16. package/.agents/RESILIENCE.md +235 -0
  17. package/.agents/SCALE_OUT.md +109 -0
  18. package/.agents/SECRETS.md +117 -0
  19. package/AGENTS.md +16 -0
  20. package/README.md +2 -2
  21. package/bin/commands/add-aws.js +396 -0
  22. package/bin/commands/add-ci.js +523 -0
  23. package/bin/commands/add-cloudflare.js +240 -0
  24. package/bin/commands/add-cloudrun.js +381 -0
  25. package/bin/commands/add-docker.js +6 -6
  26. package/bin/commands/add-migration-job.js +396 -0
  27. package/bin/commands/assets-sync.js +209 -0
  28. package/bin/commands/client-generate.js +52 -0
  29. package/bin/commands/console.js +53 -0
  30. package/bin/commands/db-migrate.js +47 -2
  31. package/bin/commands/db-studio.js +84 -0
  32. package/bin/commands/deploy-check.js +242 -13
  33. package/bin/commands/dev.js +1 -1
  34. package/bin/commands/export.js +87 -0
  35. package/bin/commands/new.js +5 -5
  36. package/bin/commands/openapi.js +1 -1
  37. package/bin/commands/secrets-pull.js +208 -0
  38. package/bin/commands/start.js +1 -1
  39. package/bin/utils/db.js +7 -1
  40. package/bin/utils/project.js +2 -2
  41. package/bin/webspresso.js +22 -0
  42. package/core/app.js +421 -0
  43. package/core/auth/authenticate.js +93 -0
  44. package/core/auth/hash.js +5 -4
  45. package/core/auth/index.js +34 -4
  46. package/core/auth/jwt.js +5 -54
  47. package/core/auth/magic-link.js +4 -5
  48. package/core/auth/manager.js +53 -10
  49. package/core/auth/middleware.js +16 -68
  50. package/core/auth/oauth.js +8 -17
  51. package/core/auth/redis-session.js +1008 -0
  52. package/core/auth/sessions.js +156 -0
  53. package/core/auth/totp.js +2 -1
  54. package/core/cache/byte-size.js +123 -0
  55. package/core/cache/cdn.js +296 -0
  56. package/core/cache/index.js +33 -0
  57. package/core/cache/lru.js +477 -0
  58. package/core/cache/tagged-lru.js +354 -0
  59. package/core/commands/registry.js +6 -20
  60. package/core/console/commands.js +148 -0
  61. package/core/console/context.js +260 -0
  62. package/core/console/eval.js +70 -0
  63. package/core/console/index.js +22 -0
  64. package/core/console/repl.js +102 -0
  65. package/core/early-hints/index.js +267 -0
  66. package/core/errors/domain.js +49 -0
  67. package/core/errors/http.js +31 -0
  68. package/core/errors/index.js +10 -0
  69. package/core/errors/normalize.js +88 -2
  70. package/core/events/discovery.js +6 -24
  71. package/core/events/dispatcher.js +9 -7
  72. package/core/events/signal.js +7 -0
  73. package/core/export/discovery.js +191 -0
  74. package/core/export/exporter.js +277 -0
  75. package/core/export/index.js +24 -0
  76. package/core/flags/evaluator.js +252 -0
  77. package/core/flags/hasher.js +37 -0
  78. package/core/flags/index.js +21 -0
  79. package/core/flags/manager.js +263 -0
  80. package/core/flags/store.js +126 -0
  81. package/core/health/index.js +41 -0
  82. package/core/health/middleware.js +213 -0
  83. package/core/health/normalize.js +140 -0
  84. package/core/health/probes.js +203 -0
  85. package/core/http/adapter.js +74 -0
  86. package/core/http/express-adapter.js +46 -0
  87. package/core/http/hono-adapter.js +531 -0
  88. package/core/http/index.js +41 -0
  89. package/core/http/pipeline.js +485 -0
  90. package/core/index.js +336 -0
  91. package/core/limits/index.js +34 -0
  92. package/core/limits/middleware.js +325 -0
  93. package/core/limits/normalize.js +222 -0
  94. package/core/logger/formatters.js +264 -0
  95. package/core/logger/index.js +260 -0
  96. package/core/logger/trace.js +137 -0
  97. package/core/metrics/collectors.js +125 -0
  98. package/core/metrics/index.js +45 -0
  99. package/core/metrics/middleware.js +230 -0
  100. package/core/metrics/registry.js +366 -0
  101. package/core/orm/batch.js +70 -0
  102. package/core/orm/cache/fingerprint.js +2 -2
  103. package/core/orm/cache/memory-provider.js +27 -110
  104. package/core/orm/d1.js +265 -0
  105. package/core/orm/index.js +176 -13
  106. package/core/orm/json-fields.js +22 -8
  107. package/core/orm/migrate-cloud.js +194 -0
  108. package/core/orm/model.js +10 -0
  109. package/core/orm/pool.js +97 -0
  110. package/core/orm/query-builder.js +72 -9
  111. package/core/orm/repository.js +318 -19
  112. package/core/orm/schema-helpers.js +8 -10
  113. package/core/orm/scopes.js +321 -1
  114. package/core/orm/transaction.js +10 -2
  115. package/core/orm/utils.js +4 -86
  116. package/core/queue/adapters/cloudflare.js +131 -0
  117. package/core/queue/adapters/database.js +4 -8
  118. package/core/queue/adapters/pubsub.js +400 -0
  119. package/core/queue/adapters/sqs.js +419 -0
  120. package/core/queue/adapters/wait-until.js +77 -0
  121. package/core/queue/index.js +8 -0
  122. package/core/queue/manager.js +66 -41
  123. package/core/rate-limit/index.js +22 -0
  124. package/core/rate-limit/limiter.js +219 -0
  125. package/core/rate-limit/stores/memory.js +129 -0
  126. package/core/rate-limit/stores/redis.js +151 -0
  127. package/core/rate-limit/utils.js +112 -0
  128. package/core/resilience/circuit-breaker.js +422 -0
  129. package/core/resilience/errors.js +63 -0
  130. package/core/resilience/index.js +53 -0
  131. package/core/resilience/registry.js +97 -0
  132. package/core/resilience/retry.js +203 -0
  133. package/core/schema-cache.js +9 -2
  134. package/core/secrets/index.js +113 -0
  135. package/core/secrets/manager.js +308 -0
  136. package/core/secrets/providers/aws.js +288 -0
  137. package/core/secrets/providers/env.js +44 -0
  138. package/core/secrets/providers/gcp.js +307 -0
  139. package/core/secrets/providers/memory.js +76 -0
  140. package/core/shutdown/http-adapter.js +4 -1
  141. package/core/shutdown/shutdown-manager.js +129 -19
  142. package/core/stream/index.js +19 -0
  143. package/core/stream/text-stream.js +326 -0
  144. package/core/studio/index.js +43 -0
  145. package/core/studio/inspector.js +258 -0
  146. package/core/studio/server.js +325 -0
  147. package/core/studio/ui.js +681 -0
  148. package/core/utils/array.js +12 -0
  149. package/core/utils/base64url.js +8 -0
  150. package/core/utils/crypto.js +118 -0
  151. package/core/utils/ensure-leading-slash.js +6 -0
  152. package/core/utils/escape-regexp.js +7 -0
  153. package/core/utils/fetch.js +6 -5
  154. package/core/utils/fs.js +144 -0
  155. package/core/utils/hmac.js +6 -0
  156. package/core/utils/json.js +98 -0
  157. package/core/utils/object.js +106 -0
  158. package/core/utils/random.js +9 -0
  159. package/core/utils/regex.js +77 -0
  160. package/core/utils/sha256.js +6 -0
  161. package/core/utils/string.js +87 -0
  162. package/core/utils/strip-trailing-slash.js +6 -0
  163. package/core/utils/time.js +96 -0
  164. package/core/utils/timeout.js +348 -0
  165. package/core/utils/url.js +106 -0
  166. package/core/utils/walk.js +7 -0
  167. package/index.d.ts +1549 -2
  168. package/index.js +272 -2
  169. package/package.json +3 -2
  170. package/plugins/admin-panel/core/api-extensions.js +2 -5
  171. package/plugins/admin-panel/index.js +13 -6
  172. package/plugins/admin-panel/modules/menu.js +1 -1
  173. package/plugins/api-key/crypto.js +3 -3
  174. package/plugins/api-key/index.js +3 -2
  175. package/plugins/api-key/scopes.js +5 -7
  176. package/plugins/audit-log/api-handlers.js +6 -15
  177. package/plugins/audit-log/index.js +3 -1
  178. package/plugins/audit-log/parse.js +3 -3
  179. package/plugins/basic-auth/index.js +3 -2
  180. package/plugins/cors.js +3 -2
  181. package/plugins/csrf/index.js +9 -12
  182. package/plugins/csv/index.js +8 -5
  183. package/plugins/dashboard/app.js +2 -2
  184. package/plugins/dashboard/index.js +5 -5
  185. package/plugins/data-exchange/record-selection.js +2 -5
  186. package/plugins/email/api-handlers.js +5 -18
  187. package/plugins/email/auth-bridge.js +3 -2
  188. package/plugins/email/index.js +11 -9
  189. package/plugins/health-check.js +2 -1
  190. package/plugins/index.js +5 -0
  191. package/plugins/lazy.js +5 -6
  192. package/plugins/mcp/discovery.js +18 -17
  193. package/plugins/polar/src/api.js +2 -1
  194. package/plugins/polar/src/webhooks.js +5 -6
  195. package/plugins/queue/index.js +18 -2
  196. package/plugins/rate-limit/index.js +3 -1
  197. package/plugins/redirect/index.js +16 -9
  198. package/plugins/rls/index.js +170 -0
  199. package/plugins/seo-checker/index.js +2 -2
  200. package/plugins/site-analytics/index.js +3 -1
  201. package/plugins/site-analytics/tracking.js +2 -2
  202. package/plugins/sitemap.js +3 -2
  203. package/plugins/swagger.js +12 -5
  204. package/plugins/upload/gcs-provider.js +148 -0
  205. package/plugins/upload/index.js +136 -77
  206. package/plugins/upload/orphan-pruner.js +4 -3
  207. package/plugins/upload/s3-provider.js +257 -0
  208. package/plugins/webhooks/crypto.js +6 -11
  209. package/plugins/webhooks/dispatcher.js +8 -8
  210. package/plugins/webhooks/index.js +33 -23
  211. package/plugins/webhooks/matcher.js +7 -11
  212. package/plugins/webhooks/sender.js +8 -11
  213. package/plugins/xlsx/index.js +7 -5
  214. package/src/api/api-loader.js +12 -95
  215. package/src/api/define-api.js +1 -0
  216. package/src/app-context.js +41 -5
  217. package/src/client-runtime/constants.js +10 -0
  218. package/src/client-runtime/mount.js +2 -3
  219. package/src/dev/banner.js +6 -2
  220. package/src/dev/client-gen.js +458 -0
  221. package/src/discovery/scan-routes.js +5 -2
  222. package/src/file-router.js +3 -3
  223. package/src/helpers.js +212 -24
  224. package/src/modules/module-discovery.js +1 -9
  225. package/src/pages/page-loader.js +13 -154
  226. package/src/plugin-manager.js +99 -18
  227. package/src/server.js +335 -133
  228. package/src/services/discovery.js +19 -40
  229. package/src/services/executor.js +95 -25
  230. package/src/services/memoize.js +3 -25
  231. package/src/services/registry.js +15 -1
  232. package/src/services/validator.js +38 -20
  233. package/src/template/precompile.js +8 -19
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Webspresso Core Zero-Dependency Rate Limiter Engine
3
+ * @module core/rate-limit
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const { RateLimiter, createRateLimiter, rateLimit } = require('./limiter');
9
+ const { MemoryRateLimitStore } = require('./stores/memory');
10
+ const { RedisRateLimitStore } = require('./stores/redis');
11
+ const { parseWindow, extractClientIp, formatResetSeconds } = require('./utils');
12
+
13
+ module.exports = {
14
+ RateLimiter,
15
+ createRateLimiter,
16
+ rateLimit,
17
+ MemoryRateLimitStore,
18
+ RedisRateLimitStore,
19
+ parseWindow,
20
+ extractClientIp,
21
+ formatResetSeconds,
22
+ };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Webspresso Core Rate Limiter Engine
3
+ * Zero-dependency, framework-agnostic rate limiting middleware for Express and Hono.
4
+ * Implements RFC 6585 and IETF Draft standard RateLimit headers.
5
+ * @module core/rate-limit/limiter
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const { parseWindow, extractClientIp, formatResetSeconds } = require('./utils');
11
+ const { MemoryRateLimitStore } = require('./stores/memory');
12
+ const { TooManyRequestsError } = require('../errors');
13
+
14
+ class RateLimiter {
15
+ /**
16
+ * @param {Object} [options]
17
+ * @param {number} [options.limit=60] - Maximum requests allowed in the time window
18
+ * @param {number} [options.max=60] - Alias for options.limit
19
+ * @param {string|number} [options.window='1m'] - Time window in ms or string ('1m', '10s', etc.)
20
+ * @param {string|number} [options.windowMs] - Alias for options.window
21
+ * @param {Object} [options.store] - Store instance (MemoryRateLimitStore or RedisRateLimitStore)
22
+ * @param {Function} [options.keyGenerator] - Custom key generator (req) => string
23
+ * @param {Function} [options.skip] - Predicate function (req) => boolean
24
+ * @param {boolean} [options.standardHeaders=true] - Send RateLimit-* headers
25
+ * @param {boolean} [options.legacyHeaders=false] - Send X-RateLimit-* headers
26
+ * @param {boolean} [options.skipFailedRequests=false] - Don't count failed requests (status >= 400)
27
+ * @param {boolean} [options.skipSuccessfulRequests=false] - Don't count successful requests (status < 400)
28
+ * @param {number} [options.statusCode=429] - Status code on limit exceeded
29
+ * @param {string|Object} [options.message] - Response body on limit exceeded
30
+ * @param {Function} [options.handler] - Custom handler when limit exceeded
31
+ */
32
+ constructor(options = {}) {
33
+ this.limit = options.limit || options.max || 60;
34
+ this.windowMs = parseWindow(options.windowMs || options.window || 60000);
35
+ this.store = options.store || new MemoryRateLimitStore();
36
+ this.keyGenerator = typeof options.keyGenerator === 'function' ? options.keyGenerator : extractClientIp;
37
+ this.skip = typeof options.skip === 'function' ? options.skip : (() => false);
38
+ this.standardHeaders = options.standardHeaders !== false;
39
+ this.legacyHeaders = Boolean(options.legacyHeaders);
40
+ this.skipFailedRequests = Boolean(options.skipFailedRequests);
41
+ this.skipSuccessfulRequests = Boolean(options.skipSuccessfulRequests);
42
+ this.statusCode = options.statusCode || 429;
43
+ this.message = options.message || {
44
+ error: 'Too Many Requests',
45
+ message: 'Too many requests, please try again later.',
46
+ statusCode: this.statusCode,
47
+ };
48
+ this.handler = typeof options.handler === 'function' ? options.handler : null;
49
+
50
+ // Bind middleware
51
+ this.middleware = this.middleware.bind(this);
52
+ }
53
+
54
+ /**
55
+ * Set standard and legacy RateLimit headers on response
56
+ * @private
57
+ */
58
+ _setHeaders(setter, limit, remaining, resetSeconds, resetDate, isExceeded) {
59
+ if (this.standardHeaders) {
60
+ setter('ratelimit-limit', String(limit));
61
+ setter('ratelimit-remaining', String(remaining));
62
+ setter('ratelimit-reset', String(resetSeconds));
63
+ setter('ratelimit-policy', `${limit};w=${Math.ceil(this.windowMs / 1000)}`);
64
+ }
65
+
66
+ if (this.legacyHeaders) {
67
+ setter('x-ratelimit-limit', String(limit));
68
+ setter('x-ratelimit-remaining', String(remaining));
69
+ setter('x-ratelimit-reset', String(Math.ceil(resetDate.getTime() / 1000)));
70
+ }
71
+
72
+ if (isExceeded) {
73
+ setter('retry-after', String(Math.max(1, resetSeconds)));
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Universal Middleware Handler
79
+ * Works on Express (req, res, next) and Hono (c, next)
80
+ */
81
+ async middleware(reqOrC, resOrNext, maybeNext) {
82
+ // 1. Detect Hono vs Express signature
83
+ const isHono = typeof reqOrC.header === 'function' && typeof reqOrC.text === 'function' && typeof resOrNext === 'function';
84
+
85
+ if (isHono) {
86
+ const c = reqOrC;
87
+ const next = resOrNext;
88
+
89
+ const reqMock = {
90
+ ip: c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || '127.0.0.1',
91
+ headers: c.req.header(),
92
+ path: c.req.path,
93
+ method: c.req.method,
94
+ };
95
+
96
+ if (this.skip(reqMock)) {
97
+ return next();
98
+ }
99
+
100
+ const key = String(this.keyGenerator(reqMock) || '127.0.0.1');
101
+ const { totalHits, resetTime } = await this.store.increment(key, this.windowMs);
102
+ const remaining = Math.max(0, this.limit - totalHits);
103
+ const resetSeconds = formatResetSeconds(resetTime);
104
+ const isExceeded = totalHits > this.limit;
105
+
106
+ this._setHeaders(
107
+ (headerName, headerVal) => c.header(headerName, headerVal),
108
+ this.limit,
109
+ remaining,
110
+ resetSeconds,
111
+ resetTime,
112
+ isExceeded
113
+ );
114
+
115
+ if (isExceeded) {
116
+ if (this.handler) {
117
+ return this.handler(c, {
118
+ totalHits,
119
+ remaining,
120
+ resetTime,
121
+ limit: this.limit,
122
+ });
123
+ }
124
+ return c.json(typeof this.message === 'object' ? this.message : { message: this.message }, this.statusCode);
125
+ }
126
+
127
+ return next();
128
+ }
129
+
130
+ // 2. Standard Express signature: (req, res, next)
131
+ const req = reqOrC;
132
+ const res = resOrNext;
133
+ const next = maybeNext || (() => {});
134
+
135
+ if (this.skip(req)) {
136
+ return next();
137
+ }
138
+
139
+ const key = String(this.keyGenerator(req) || '127.0.0.1');
140
+ const { totalHits, resetTime } = await this.store.increment(key, this.windowMs);
141
+ const remaining = Math.max(0, this.limit - totalHits);
142
+ const resetSeconds = formatResetSeconds(resetTime);
143
+ const isExceeded = totalHits > this.limit;
144
+
145
+ // Attach info to request
146
+ req.rateLimit = {
147
+ limit: this.limit,
148
+ current: totalHits,
149
+ remaining,
150
+ resetTime,
151
+ };
152
+
153
+ if (res && typeof res.setHeader === 'function') {
154
+ this._setHeaders(
155
+ (name, val) => res.setHeader(name, val),
156
+ this.limit,
157
+ remaining,
158
+ resetSeconds,
159
+ resetTime,
160
+ isExceeded
161
+ );
162
+ }
163
+
164
+ // Handle skip on success / failure
165
+ if (res && typeof res.on === 'function' && (this.skipFailedRequests || this.skipSuccessfulRequests)) {
166
+ res.on('finish', () => {
167
+ const isFailed = res.statusCode >= 400;
168
+ if (isFailed && this.skipFailedRequests) {
169
+ this.store.decrement(key).catch(() => {});
170
+ } else if (!isFailed && this.skipSuccessfulRequests) {
171
+ this.store.decrement(key).catch(() => {});
172
+ }
173
+ });
174
+ }
175
+
176
+ if (isExceeded) {
177
+ if (this.handler) {
178
+ return this.handler(req, res, next, {
179
+ totalHits,
180
+ remaining,
181
+ resetTime,
182
+ limit: this.limit,
183
+ });
184
+ }
185
+
186
+ if (res && typeof res.status === 'function') {
187
+ if (typeof this.message === 'object') {
188
+ return res.status(this.statusCode).json(this.message);
189
+ }
190
+ return res.status(this.statusCode).send(String(this.message));
191
+ }
192
+
193
+ const err = new TooManyRequestsError(
194
+ typeof this.message === 'string' ? this.message : this.message.message || 'Too Many Requests'
195
+ );
196
+ return next(err);
197
+ }
198
+
199
+ return next();
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Factory helper for creating rate limiter middleware
205
+ * @param {Object} [options]
206
+ * @returns {import('express').RequestHandler & { limiter: RateLimiter }}
207
+ */
208
+ function createRateLimiter(options = {}) {
209
+ const limiter = new RateLimiter(options);
210
+ const mw = (req, res, next) => limiter.middleware(req, res, next);
211
+ mw.limiter = limiter;
212
+ return mw;
213
+ }
214
+
215
+ module.exports = {
216
+ RateLimiter,
217
+ createRateLimiter,
218
+ rateLimit: createRateLimiter,
219
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Webspresso In-Memory Rate Limit Store
3
+ * Sliding window algorithm with automatic cleanup and memory bound safety.
4
+ * @module core/rate-limit/stores/memory
5
+ */
6
+
7
+ 'use strict';
8
+
9
+ class MemoryRateLimitStore {
10
+ /**
11
+ * @param {Object} [options]
12
+ * @param {number} [options.cleanupIntervalMs=30000] - Interval to sweep expired keys
13
+ */
14
+ constructor(options = {}) {
15
+ /** @type {Map<string, number[]>} Map of key -> timestamps */
16
+ this.hits = new Map();
17
+ this.cleanupIntervalMs = options.cleanupIntervalMs || 30000;
18
+
19
+ // Periodic sweep to prevent memory growth
20
+ this._cleanupTimer = setInterval(() => {
21
+ this.sweep();
22
+ }, this.cleanupIntervalMs);
23
+
24
+ if (this._cleanupTimer.unref) {
25
+ this._cleanupTimer.unref();
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Increment request count for a given key within windowMs
31
+ * @param {string} key
32
+ * @param {number} windowMs
33
+ * @returns {Promise<{ totalHits: number, resetTime: Date }>}
34
+ */
35
+ async increment(key, windowMs) {
36
+ const now = Date.now();
37
+ const threshold = now - windowMs;
38
+
39
+ let timestamps = this.hits.get(key);
40
+ if (!timestamps) {
41
+ timestamps = [];
42
+ this.hits.set(key, timestamps);
43
+ }
44
+
45
+ // Filter out expired timestamps
46
+ let validIndex = 0;
47
+ while (validIndex < timestamps.length && timestamps[validIndex] <= threshold) {
48
+ validIndex++;
49
+ }
50
+ if (validIndex > 0) {
51
+ timestamps.splice(0, validIndex);
52
+ }
53
+
54
+ timestamps.push(now);
55
+
56
+ const oldest = timestamps[0];
57
+ const resetTime = new Date(oldest + windowMs);
58
+
59
+ return {
60
+ totalHits: timestamps.length,
61
+ resetTime,
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Decrement request count (e.g. when request is skipped or failed)
67
+ * @param {string} key
68
+ * @returns {Promise<void>}
69
+ */
70
+ async decrement(key) {
71
+ const timestamps = this.hits.get(key);
72
+ if (timestamps && timestamps.length > 0) {
73
+ timestamps.pop();
74
+ if (timestamps.length === 0) {
75
+ this.hits.delete(key);
76
+ }
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Reset hits for a single key
82
+ * @param {string} key
83
+ * @returns {Promise<void>}
84
+ */
85
+ async resetKey(key) {
86
+ this.hits.delete(key);
87
+ }
88
+
89
+ /**
90
+ * Reset all stored keys
91
+ * @returns {Promise<void>}
92
+ */
93
+ async resetAll() {
94
+ this.hits.clear();
95
+ }
96
+
97
+ /**
98
+ * Sweep stale keys that have no recent hits
99
+ */
100
+ sweep() {
101
+ const now = Date.now();
102
+ for (const [key, timestamps] of this.hits.entries()) {
103
+ if (timestamps.length === 0) {
104
+ this.hits.delete(key);
105
+ } else {
106
+ const latest = timestamps[timestamps.length - 1];
107
+ // If the newest hit is older than 1 hour, safely purge
108
+ if (now - latest > 3600000) {
109
+ this.hits.delete(key);
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Destroy store and stop background cleanup timer
117
+ */
118
+ destroy() {
119
+ if (this._cleanupTimer) {
120
+ clearInterval(this._cleanupTimer);
121
+ this._cleanupTimer = null;
122
+ }
123
+ this.hits.clear();
124
+ }
125
+ }
126
+
127
+ module.exports = {
128
+ MemoryRateLimitStore,
129
+ };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Webspresso Redis Rate Limit Store
3
+ * Distributed sliding window rate limiter backed by Redis.
4
+ * Supports official redis, ioredis, or Webspresso MiniRedisClient.
5
+ * @module core/rate-limit/stores/redis
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ class RedisRateLimitStore {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {Object} options.client - Redis client instance
14
+ * @param {string} [options.prefix='rl:'] - Key prefix
15
+ * @param {boolean} [options.passOnStoreError=true] - Fail-open on Redis connection errors
16
+ */
17
+ constructor(options = {}) {
18
+ if (!options.client) {
19
+ throw new Error('[RedisRateLimitStore] Requires an initialized redis client');
20
+ }
21
+ this.client = options.client;
22
+ this.prefix = options.prefix || 'rl:';
23
+ this.passOnStoreError = options.passOnStoreError !== false;
24
+ }
25
+
26
+ _getKey(key) {
27
+ return `${this.prefix}${key}`;
28
+ }
29
+
30
+ /**
31
+ * Increment request count for key within windowMs
32
+ * @param {string} key
33
+ * @param {number} windowMs
34
+ * @returns {Promise<{ totalHits: number, resetTime: Date }>}
35
+ */
36
+ async increment(key, windowMs) {
37
+ const redisKey = this._getKey(key);
38
+ const now = Date.now();
39
+ const threshold = now - windowMs;
40
+ const member = `${now}-${Math.random().toString(36).slice(2, 8)}`;
41
+
42
+ try {
43
+ // 1. If client supports multi() with sorted sets (ioredis / node-redis)
44
+ if (typeof this.client.multi === 'function') {
45
+ const multi = this.client.multi();
46
+ multi.zremrangebyscore(redisKey, 0, threshold);
47
+ multi.zadd(redisKey, now, member);
48
+ multi.zcard(redisKey);
49
+ multi.pexpire(redisKey, windowMs + 1000);
50
+
51
+ const execRes = await multi.exec();
52
+ // Handle ioredis tuple [err, res] vs node-redis single value
53
+ const zcardRes = execRes[2];
54
+ const count = Array.isArray(zcardRes) ? zcardRes[1] : zcardRes;
55
+
56
+ return {
57
+ totalHits: Number(count) || 1,
58
+ resetTime: new Date(now + windowMs),
59
+ };
60
+ }
61
+
62
+ // 2. Fallback using individual commands or INCR + PEXPIRE
63
+ if (typeof this.client.zremrangebyscore === 'function' && typeof this.client.zadd === 'function') {
64
+ await this.client.zremrangebyscore(redisKey, 0, threshold);
65
+ await this.client.zadd(redisKey, now, member);
66
+ const count = await this.client.zcard(redisKey);
67
+ if (typeof this.client.pexpire === 'function') {
68
+ await this.client.pexpire(redisKey, windowMs + 1000);
69
+ }
70
+ return {
71
+ totalHits: Number(count) || 1,
72
+ resetTime: new Date(now + windowMs),
73
+ };
74
+ }
75
+
76
+ // 3. Fallback using key per time bucket
77
+ const bucket = Math.floor(now / windowMs);
78
+ const bucketKey = `${redisKey}:${bucket}`;
79
+ let count = 1;
80
+ if (typeof this.client.incr === 'function') {
81
+ count = await this.client.incr(bucketKey);
82
+ if (count === 1 && typeof this.client.pexpire === 'function') {
83
+ await this.client.pexpire(bucketKey, windowMs + 1000);
84
+ }
85
+ }
86
+
87
+ return {
88
+ totalHits: Number(count) || 1,
89
+ resetTime: new Date((bucket + 1) * windowMs),
90
+ };
91
+ } catch (err) {
92
+ if (this.passOnStoreError) {
93
+ // Fail-open: allow request to proceed if Redis is down
94
+ return {
95
+ totalHits: 1,
96
+ resetTime: new Date(now + windowMs),
97
+ };
98
+ }
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Decrement request count
105
+ * @param {string} key
106
+ * @returns {Promise<void>}
107
+ */
108
+ async decrement(key) {
109
+ const redisKey = this._getKey(key);
110
+ try {
111
+ if (typeof this.client.zpopmax === 'function') {
112
+ await this.client.zpopmax(redisKey, 1);
113
+ } else if (typeof this.client.decr === 'function') {
114
+ await this.client.decr(redisKey);
115
+ }
116
+ } catch (_) {}
117
+ }
118
+
119
+ /**
120
+ * Reset hits for a single key
121
+ * @param {string} key
122
+ * @returns {Promise<void>}
123
+ */
124
+ async resetKey(key) {
125
+ const redisKey = this._getKey(key);
126
+ try {
127
+ if (typeof this.client.del === 'function') {
128
+ await this.client.del(redisKey);
129
+ }
130
+ } catch (_) {}
131
+ }
132
+
133
+ /**
134
+ * Reset all keys matching prefix
135
+ * @returns {Promise<void>}
136
+ */
137
+ async resetAll() {
138
+ try {
139
+ if (typeof this.client.keys === 'function' && typeof this.client.del === 'function') {
140
+ const keys = await this.client.keys(`${this.prefix}*`);
141
+ if (keys && keys.length > 0) {
142
+ await this.client.del(...keys);
143
+ }
144
+ }
145
+ } catch (_) {}
146
+ }
147
+ }
148
+
149
+ module.exports = {
150
+ RedisRateLimitStore,
151
+ };
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Webspresso Core Rate Limit Utilities
3
+ * @module core/rate-limit/utils
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ /**
9
+ * Parse window duration string or number into milliseconds
10
+ * Supports: 500, '500ms', '10s', '1m', '15m', '1h', '1d'
11
+ * @param {string|number} window
12
+ * @returns {number} duration in milliseconds
13
+ */
14
+ function parseWindow(window) {
15
+ if (typeof window === 'number' && !Number.isNaN(window) && window > 0) {
16
+ return Math.floor(window);
17
+ }
18
+
19
+ if (typeof window === 'string') {
20
+ const trimmed = window.trim().toLowerCase();
21
+ const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
22
+ if (match) {
23
+ const val = parseFloat(match[1]);
24
+ const unit = match[2] || 'ms';
25
+
26
+ switch (unit) {
27
+ case 'ms': return Math.floor(val);
28
+ case 's': return Math.floor(val * 1000);
29
+ case 'm': return Math.floor(val * 60 * 1000);
30
+ case 'h': return Math.floor(val * 60 * 60 * 1000);
31
+ case 'd': return Math.floor(val * 24 * 60 * 60 * 1000);
32
+ default: return Math.floor(val);
33
+ }
34
+ }
35
+ }
36
+
37
+ // Fallback default: 60,000ms (1 minute)
38
+ return 60000;
39
+ }
40
+
41
+ /**
42
+ * Extract client IP address from incoming request with reverse proxy support
43
+ * @param {Object} req
44
+ * @returns {string} client IP
45
+ */
46
+ function extractClientIp(req) {
47
+ if (!req) return '127.0.0.1';
48
+
49
+ // 1. Direct req.ip (populated by Express if trust proxy is enabled)
50
+ if (req.ip && typeof req.ip === 'string') {
51
+ return req.ip;
52
+ }
53
+
54
+ const headers = req.headers || (req.header ? {
55
+ 'cf-connecting-ip': req.header('cf-connecting-ip'),
56
+ 'x-forwarded-for': req.header('x-forwarded-for'),
57
+ 'x-real-ip': req.header('x-real-ip'),
58
+ 'fastly-client-ip': req.header('fastly-client-ip'),
59
+ } : {});
60
+
61
+ // 2. Cloudflare Connecting IP
62
+ const cfIp = headers['cf-connecting-ip'];
63
+ if (cfIp && typeof cfIp === 'string') {
64
+ return cfIp.trim();
65
+ }
66
+
67
+ // 3. Fastly / Akamai / True-Client-IP
68
+ const fastlyIp = headers['fastly-client-ip'] || headers['true-client-ip'];
69
+ if (fastlyIp && typeof fastlyIp === 'string') {
70
+ return fastlyIp.trim();
71
+ }
72
+
73
+ // 4. Standard X-Forwarded-For (take the first client IP in chain)
74
+ const forwardedFor = headers['x-forwarded-for'];
75
+ if (forwardedFor && typeof forwardedFor === 'string') {
76
+ const parts = forwardedFor.split(',');
77
+ if (parts.length > 0 && parts[0].trim()) {
78
+ return parts[0].trim();
79
+ }
80
+ }
81
+
82
+ // 5. X-Real-IP
83
+ const realIp = headers['x-real-ip'];
84
+ if (realIp && typeof realIp === 'string') {
85
+ return realIp.trim();
86
+ }
87
+
88
+ // 6. Socket remote address
89
+ const socketIp = req.socket?.remoteAddress || req.connection?.remoteAddress;
90
+ if (socketIp && typeof socketIp === 'string') {
91
+ return socketIp.replace(/^::ffff:/, ''); // Normalize IPv4-mapped IPv6
92
+ }
93
+
94
+ return '127.0.0.1';
95
+ }
96
+
97
+ /**
98
+ * Format reset time as seconds remaining from current timestamp
99
+ * @param {Date|number} resetTime
100
+ * @returns {number} seconds until reset
101
+ */
102
+ function formatResetSeconds(resetTime) {
103
+ const targetTime = resetTime instanceof Date ? resetTime.getTime() : Number(resetTime);
104
+ const diffMs = targetTime - Date.now();
105
+ return Math.max(0, Math.ceil(diffMs / 1000));
106
+ }
107
+
108
+ module.exports = {
109
+ parseWindow,
110
+ extractClientIp,
111
+ formatResetSeconds,
112
+ };