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.
- package/.agents/AGENTS.md +21 -2
- package/.agents/ARCHITECTURE.md +23 -0
- package/.agents/CACHE.md +147 -0
- package/.agents/CLI.md +220 -8
- package/.agents/EARLY_HINTS.md +120 -0
- package/.agents/FLAGS.md +118 -0
- package/.agents/HEALTH.md +118 -0
- package/.agents/LIMITS.md +109 -0
- package/.agents/LOGGING.md +93 -0
- package/.agents/METRICS.md +127 -0
- package/.agents/MIGRATION_JOBS.md +95 -0
- package/.agents/ORM.md +155 -1
- package/.agents/PLUGINS.md +38 -3
- package/.agents/QUEUE.md +42 -0
- package/.agents/RATE_LIMIT.md +147 -0
- package/.agents/RESILIENCE.md +235 -0
- package/.agents/SCALE_OUT.md +109 -0
- package/.agents/SECRETS.md +117 -0
- package/AGENTS.md +16 -0
- package/README.md +2 -2
- package/bin/commands/add-aws.js +396 -0
- package/bin/commands/add-ci.js +523 -0
- package/bin/commands/add-cloudflare.js +240 -0
- package/bin/commands/add-cloudrun.js +381 -0
- package/bin/commands/add-docker.js +6 -6
- package/bin/commands/add-migration-job.js +396 -0
- package/bin/commands/assets-sync.js +209 -0
- package/bin/commands/client-generate.js +52 -0
- package/bin/commands/console.js +53 -0
- package/bin/commands/db-migrate.js +47 -2
- package/bin/commands/db-studio.js +84 -0
- package/bin/commands/deploy-check.js +242 -13
- package/bin/commands/dev.js +1 -1
- package/bin/commands/export.js +87 -0
- package/bin/commands/new.js +5 -5
- package/bin/commands/openapi.js +1 -1
- package/bin/commands/secrets-pull.js +208 -0
- package/bin/commands/start.js +1 -1
- package/bin/utils/db.js +7 -1
- package/bin/utils/project.js +2 -2
- package/bin/webspresso.js +22 -0
- package/core/app.js +421 -0
- package/core/auth/authenticate.js +93 -0
- package/core/auth/hash.js +5 -4
- package/core/auth/index.js +34 -4
- package/core/auth/jwt.js +5 -54
- package/core/auth/magic-link.js +4 -5
- package/core/auth/manager.js +53 -10
- package/core/auth/middleware.js +16 -68
- package/core/auth/oauth.js +8 -17
- package/core/auth/redis-session.js +1008 -0
- package/core/auth/sessions.js +156 -0
- package/core/auth/totp.js +2 -1
- package/core/cache/byte-size.js +123 -0
- package/core/cache/cdn.js +296 -0
- package/core/cache/index.js +33 -0
- package/core/cache/lru.js +477 -0
- package/core/cache/tagged-lru.js +354 -0
- package/core/commands/registry.js +6 -20
- package/core/console/commands.js +148 -0
- package/core/console/context.js +260 -0
- package/core/console/eval.js +70 -0
- package/core/console/index.js +22 -0
- package/core/console/repl.js +102 -0
- package/core/early-hints/index.js +267 -0
- package/core/errors/domain.js +49 -0
- package/core/errors/http.js +31 -0
- package/core/errors/index.js +10 -0
- package/core/errors/normalize.js +88 -2
- package/core/events/discovery.js +6 -24
- package/core/events/dispatcher.js +9 -7
- package/core/events/signal.js +7 -0
- package/core/export/discovery.js +191 -0
- package/core/export/exporter.js +277 -0
- package/core/export/index.js +24 -0
- package/core/flags/evaluator.js +252 -0
- package/core/flags/hasher.js +37 -0
- package/core/flags/index.js +21 -0
- package/core/flags/manager.js +263 -0
- package/core/flags/store.js +126 -0
- package/core/health/index.js +41 -0
- package/core/health/middleware.js +213 -0
- package/core/health/normalize.js +140 -0
- package/core/health/probes.js +203 -0
- package/core/http/adapter.js +74 -0
- package/core/http/express-adapter.js +46 -0
- package/core/http/hono-adapter.js +531 -0
- package/core/http/index.js +41 -0
- package/core/http/pipeline.js +485 -0
- package/core/index.js +336 -0
- package/core/limits/index.js +34 -0
- package/core/limits/middleware.js +325 -0
- package/core/limits/normalize.js +222 -0
- package/core/logger/formatters.js +264 -0
- package/core/logger/index.js +260 -0
- package/core/logger/trace.js +137 -0
- package/core/metrics/collectors.js +125 -0
- package/core/metrics/index.js +45 -0
- package/core/metrics/middleware.js +230 -0
- package/core/metrics/registry.js +366 -0
- package/core/orm/batch.js +70 -0
- package/core/orm/cache/fingerprint.js +2 -2
- package/core/orm/cache/memory-provider.js +27 -110
- package/core/orm/d1.js +265 -0
- package/core/orm/index.js +176 -13
- package/core/orm/json-fields.js +22 -8
- package/core/orm/migrate-cloud.js +194 -0
- package/core/orm/model.js +10 -0
- package/core/orm/pool.js +97 -0
- package/core/orm/query-builder.js +72 -9
- package/core/orm/repository.js +318 -19
- package/core/orm/schema-helpers.js +8 -10
- package/core/orm/scopes.js +321 -1
- package/core/orm/transaction.js +10 -2
- package/core/orm/utils.js +4 -86
- package/core/queue/adapters/cloudflare.js +131 -0
- package/core/queue/adapters/database.js +4 -8
- package/core/queue/adapters/pubsub.js +400 -0
- package/core/queue/adapters/sqs.js +419 -0
- package/core/queue/adapters/wait-until.js +77 -0
- package/core/queue/index.js +8 -0
- package/core/queue/manager.js +66 -41
- package/core/rate-limit/index.js +22 -0
- package/core/rate-limit/limiter.js +219 -0
- package/core/rate-limit/stores/memory.js +129 -0
- package/core/rate-limit/stores/redis.js +151 -0
- package/core/rate-limit/utils.js +112 -0
- package/core/resilience/circuit-breaker.js +422 -0
- package/core/resilience/errors.js +63 -0
- package/core/resilience/index.js +53 -0
- package/core/resilience/registry.js +97 -0
- package/core/resilience/retry.js +203 -0
- package/core/schema-cache.js +9 -2
- package/core/secrets/index.js +113 -0
- package/core/secrets/manager.js +308 -0
- package/core/secrets/providers/aws.js +288 -0
- package/core/secrets/providers/env.js +44 -0
- package/core/secrets/providers/gcp.js +307 -0
- package/core/secrets/providers/memory.js +76 -0
- package/core/shutdown/http-adapter.js +4 -1
- package/core/shutdown/shutdown-manager.js +129 -19
- package/core/stream/index.js +19 -0
- package/core/stream/text-stream.js +326 -0
- package/core/studio/index.js +43 -0
- package/core/studio/inspector.js +258 -0
- package/core/studio/server.js +325 -0
- package/core/studio/ui.js +681 -0
- package/core/utils/array.js +12 -0
- package/core/utils/base64url.js +8 -0
- package/core/utils/crypto.js +118 -0
- package/core/utils/ensure-leading-slash.js +6 -0
- package/core/utils/escape-regexp.js +7 -0
- package/core/utils/fetch.js +6 -5
- package/core/utils/fs.js +144 -0
- package/core/utils/hmac.js +6 -0
- package/core/utils/json.js +98 -0
- package/core/utils/object.js +106 -0
- package/core/utils/random.js +9 -0
- package/core/utils/regex.js +77 -0
- package/core/utils/sha256.js +6 -0
- package/core/utils/string.js +87 -0
- package/core/utils/strip-trailing-slash.js +6 -0
- package/core/utils/time.js +96 -0
- package/core/utils/timeout.js +348 -0
- package/core/utils/url.js +106 -0
- package/core/utils/walk.js +7 -0
- package/index.d.ts +1549 -2
- package/index.js +272 -2
- package/package.json +3 -2
- package/plugins/admin-panel/core/api-extensions.js +2 -5
- package/plugins/admin-panel/index.js +13 -6
- package/plugins/admin-panel/modules/menu.js +1 -1
- package/plugins/api-key/crypto.js +3 -3
- package/plugins/api-key/index.js +3 -2
- package/plugins/api-key/scopes.js +5 -7
- package/plugins/audit-log/api-handlers.js +6 -15
- package/plugins/audit-log/index.js +3 -1
- package/plugins/audit-log/parse.js +3 -3
- package/plugins/basic-auth/index.js +3 -2
- package/plugins/cors.js +3 -2
- package/plugins/csrf/index.js +9 -12
- package/plugins/csv/index.js +8 -5
- package/plugins/dashboard/app.js +2 -2
- package/plugins/dashboard/index.js +5 -5
- package/plugins/data-exchange/record-selection.js +2 -5
- package/plugins/email/api-handlers.js +5 -18
- package/plugins/email/auth-bridge.js +3 -2
- package/plugins/email/index.js +11 -9
- package/plugins/health-check.js +2 -1
- package/plugins/index.js +5 -0
- package/plugins/lazy.js +5 -6
- package/plugins/mcp/discovery.js +18 -17
- package/plugins/polar/src/api.js +2 -1
- package/plugins/polar/src/webhooks.js +5 -6
- package/plugins/queue/index.js +18 -2
- package/plugins/rate-limit/index.js +3 -1
- package/plugins/redirect/index.js +16 -9
- package/plugins/rls/index.js +170 -0
- package/plugins/seo-checker/index.js +2 -2
- package/plugins/site-analytics/index.js +3 -1
- package/plugins/site-analytics/tracking.js +2 -2
- package/plugins/sitemap.js +3 -2
- package/plugins/swagger.js +12 -5
- package/plugins/upload/gcs-provider.js +148 -0
- package/plugins/upload/index.js +136 -77
- package/plugins/upload/orphan-pruner.js +4 -3
- package/plugins/upload/s3-provider.js +257 -0
- package/plugins/webhooks/crypto.js +6 -11
- package/plugins/webhooks/dispatcher.js +8 -8
- package/plugins/webhooks/index.js +33 -23
- package/plugins/webhooks/matcher.js +7 -11
- package/plugins/webhooks/sender.js +8 -11
- package/plugins/xlsx/index.js +7 -5
- package/src/api/api-loader.js +12 -95
- package/src/api/define-api.js +1 -0
- package/src/app-context.js +41 -5
- package/src/client-runtime/constants.js +10 -0
- package/src/client-runtime/mount.js +2 -3
- package/src/dev/banner.js +6 -2
- package/src/dev/client-gen.js +458 -0
- package/src/discovery/scan-routes.js +5 -2
- package/src/file-router.js +3 -3
- package/src/helpers.js +212 -24
- package/src/modules/module-discovery.js +1 -9
- package/src/pages/page-loader.js +13 -154
- package/src/plugin-manager.js +99 -18
- package/src/server.js +335 -133
- package/src/services/discovery.js +19 -40
- package/src/services/executor.js +95 -25
- package/src/services/memoize.js +3 -25
- package/src/services/registry.js +15 -1
- package/src/services/validator.js +38 -20
- 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
|
+
};
|