@mandujs/core 0.53.3 → 0.54.0
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 +654 -654
- package/package.json +2 -2
- package/src/a11y/__tests__/run-audit.test.ts +333 -333
- package/src/a11y/fix-hints.ts +76 -76
- package/src/a11y/index.ts +18 -18
- package/src/a11y/run-audit.ts +394 -394
- package/src/a11y/types.ts +125 -125
- package/src/auth/__tests__/login.test.ts +1 -1
- package/src/auth/__tests__/password.test.ts +122 -122
- package/src/auth/__tests__/tokens.test.ts +274 -274
- package/src/auth/__tests__/verification.test.ts +274 -274
- package/src/auth/index.ts +76 -76
- package/src/auth/login.ts +225 -225
- package/src/auth/password.ts +120 -120
- package/src/auth/reset.ts +243 -243
- package/src/auth/tokens.ts +612 -612
- package/src/auth/verification.ts +253 -253
- package/src/brain/__tests__/redactor.test.ts +94 -94
- package/src/brain/adapters/__tests__/_helpers.ts +83 -83
- package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
- package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
- package/src/brain/adapters/anthropic-oauth.ts +1 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -300
- package/src/brain/adapters/index.ts +319 -319
- package/src/brain/adapters/oauth-flow.ts +439 -439
- package/src/brain/consent.ts +240 -240
- package/src/brain/credentials.ts +396 -396
- package/src/bundler/__tests__/build-runner.ts +113 -113
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
- package/src/bundler/__tests__/extended-watch.test.ts +711 -711
- package/src/bundler/__tests__/fast-refresh.test.ts +10 -10
- package/src/bundler/__tests__/hdr.test.ts +353 -353
- package/src/bundler/__tests__/hmr-client.test.ts +532 -532
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -266
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
- package/src/bundler/__tests__/reverse-import-graph.test.ts +519 -519
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
- package/src/bundler/budget.ts +404 -404
- package/src/bundler/build.test.ts +179 -179
- package/src/bundler/build.ts +55 -55
- package/src/bundler/dev.ts +42 -42
- package/src/bundler/manifest-schema.ts +301 -301
- package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +263 -263
- package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
- package/src/bundler/plugins/block-generated-imports.ts +155 -155
- package/src/bundler/plugins/index.ts +83 -83
- package/src/bundler/plugins/react-compiler-config.ts +108 -108
- package/src/bundler/plugins/react-compiler-lint.ts +253 -253
- package/src/bundler/plugins/react-compiler.ts +162 -162
- package/src/bundler/reverse-import-graph.ts +339 -339
- package/src/bundler/safe-build.test.ts +201 -201
- package/src/bundler/safe-build.ts +103 -103
- package/src/bundler/scenario-matrix.ts +229 -229
- package/src/bundler/types.ts +10 -10
- package/src/bundler/vendor-cache-types.ts +130 -130
- package/src/bundler/vendor-cache.ts +526 -526
- package/src/change/snapshot.ts +18 -18
- package/src/client/Form.tsx +105 -105
- package/src/client/Link.tsx +9 -9
- package/src/client/__tests__/use-sse.test.ts +153 -153
- package/src/client/globals.ts +1 -1
- package/src/client/hooks.ts +362 -362
- package/src/client/hydrate.ts +340 -340
- package/src/client/prefetch-helper.ts +55 -55
- package/src/client/router.ts +11 -11
- package/src/client/runtime.ts +47 -47
- package/src/client/serialize.ts +404 -404
- package/src/client/use-fetch.ts +6 -6
- package/src/client/use-head.ts +197 -197
- package/src/client/use-sse.ts +378 -378
- package/src/client/window-state.ts +101 -101
- package/src/components/Image.tsx +162 -162
- package/src/config/validate.ts +3 -3
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/collection.ts +8 -8
- package/src/content/content-layer.ts +7 -7
- package/src/content/data-store.ts +245 -245
- package/src/content/frontmatter.ts +189 -189
- package/src/content/loader-context.ts +171 -171
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +172 -172
- package/src/content/loaders/glob.ts +253 -253
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/prebuild.test.ts +571 -571
- package/src/content/prebuild.ts +636 -636
- package/src/content/schema.ts +20 -20
- package/src/content/sidebar.ts +630 -630
- package/src/content/slug.ts +110 -110
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/define.ts +11 -11
- package/src/contract/index.ts +1 -1
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +410 -410
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/route-helpers.ts +1 -1
- package/src/contract/rpc.ts +443 -443
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/db/__tests__/db.test.ts +482 -482
- package/src/db/index.ts +138 -138
- package/src/db/migrations/history-table.ts +345 -345
- package/src/db/migrations/index.ts +3 -3
- package/src/db/migrations/lock.ts +324 -324
- package/src/db/migrations/runner.ts +650 -650
- package/src/deploy/cache.ts +140 -140
- package/src/deploy/compile/vercel.ts +344 -344
- package/src/deploy/index.ts +87 -87
- package/src/deploy/inference/brain.ts +268 -268
- package/src/deploy/inference/context.ts +182 -182
- package/src/deploy/inference/filling-extract.ts +245 -245
- package/src/deploy/inference/heuristic.ts +182 -182
- package/src/deploy/intent.ts +173 -173
- package/src/deploy/plan.ts +178 -178
- package/src/design/__tests__/agents-link.test.ts +109 -109
- package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
- package/src/design/__tests__/lint.test.ts +110 -110
- package/src/design/__tests__/parser.test.ts +195 -195
- package/src/design/__tests__/tailwind-theme.test.ts +229 -229
- package/src/design/agents-link.ts +165 -165
- package/src/design/diff.ts +138 -138
- package/src/design/extract.ts +285 -285
- package/src/design/index.ts +102 -102
- package/src/design/lint.ts +209 -209
- package/src/design/parser.ts +555 -555
- package/src/design/patch.ts +241 -241
- package/src/design/scaffold.ts +147 -147
- package/src/design/tailwind-theme.ts +441 -441
- package/src/design/types.ts +210 -210
- package/src/desktop/__tests__/webview-fallback.test.ts +254 -254
- package/src/desktop/__tests__/window.test.ts +248 -248
- package/src/desktop/__tests__/worker.test.ts +266 -266
- package/src/desktop/index.ts +43 -43
- package/src/desktop/types.ts +158 -158
- package/src/desktop/worker.ts +180 -180
- package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
- package/src/dev-error-overlay/index.ts +30 -30
- package/src/dev-error-overlay/overlay-injector.ts +243 -243
- package/src/dev-error-overlay/overlay-styles.ts +52 -52
- package/src/dev-error-overlay/types.ts +66 -66
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +25 -25
- package/src/devtools/client/catchers/error-catcher.ts +344 -344
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/mandu-character.tsx +331 -331
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
- package/src/devtools/client/components/panel/islands-panel.tsx +320 -320
- package/src/devtools/client/components/panel/network-panel.tsx +291 -291
- package/src/devtools/client/components/panel/panel-container.tsx +500 -500
- package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +265 -265
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +450 -450
- package/src/devtools/types.ts +35 -35
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +233 -233
- package/src/devtools/worker/worker-manager.ts +410 -410
- package/src/diagnose/__tests__/checks.test.ts +451 -451
- package/src/diagnose/checks.ts +832 -832
- package/src/diagnose/index.ts +17 -17
- package/src/diagnose/run.ts +93 -93
- package/src/diagnose/types.ts +53 -53
- package/src/email/__tests__/email.test.ts +355 -355
- package/src/email/index.ts +282 -282
- package/src/email/smtp.ts +64 -64
- package/src/error/domains.ts +265 -265
- package/src/error/result.ts +60 -60
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/__tests__/session-sqlite.test.ts +454 -454
- package/src/filling/auth.ts +308 -308
- package/src/filling/cookie-codec.ts +299 -299
- package/src/filling/deps.ts +265 -265
- package/src/filling/session-sqlite.ts +617 -617
- package/src/filling/sse.ts +5 -5
- package/src/filling/ws.ts +78 -78
- package/src/generator/index.ts +3 -3
- package/src/guard/__tests__/design-inline-class.test.ts +219 -219
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/define-rule.ts +243 -243
- package/src/guard/design-inline-class.ts +393 -393
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/graph.ts +1 -1
- package/src/guard/healing.ts +36 -36
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/reporter.ts +442 -442
- package/src/guard/rule-presets.ts +379 -379
- package/src/guard/semantic-slots.ts +1 -1
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/tsgolint-bridge.ts +512 -512
- package/src/guard/types.ts +348 -348
- package/src/guard/watcher.ts +405 -405
- package/src/i18n/define.ts +126 -126
- package/src/i18n/index.ts +52 -52
- package/src/i18n/message-registry.ts +173 -173
- package/src/i18n/types.ts +112 -112
- package/src/id/index.ts +105 -105
- package/src/index.ts +2 -2
- package/src/kitchen/api/agent-devtools-api.ts +779 -544
- package/src/kitchen/api/errors-grouping.ts +126 -0
- package/src/kitchen/kitchen-handler.ts +192 -63
- package/src/kitchen/kitchen-ui.ts +842 -464
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/middleware/bridge.ts +147 -147
- package/src/middleware/compose.ts +134 -134
- package/src/middleware/compress.ts +62 -62
- package/src/middleware/cors.ts +47 -47
- package/src/middleware/csrf.ts +328 -328
- package/src/middleware/define.ts +132 -132
- package/src/middleware/jwt.ts +134 -134
- package/src/middleware/logger.ts +58 -58
- package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
- package/src/middleware/oauth/index.ts +505 -505
- package/src/middleware/oauth/providers.ts +115 -115
- package/src/middleware/rate-limit/index.ts +522 -522
- package/src/middleware/rate-limit/sqlite-store.ts +382 -382
- package/src/middleware/scheduler-cron.ts +96 -96
- package/src/middleware/secure/__tests__/secure.test.ts +360 -360
- package/src/middleware/secure/csp.ts +193 -193
- package/src/middleware/session.ts +174 -174
- package/src/middleware/timeout.ts +55 -55
- package/src/observability/logger-adapter.ts +36 -36
- package/src/observability/sqlite-store.ts +254 -254
- package/src/openapi/generator.ts +1 -1
- package/src/openapi/openapi.test.ts +43 -43
- package/src/perf/__tests__/user-marks.test.ts +354 -354
- package/src/perf/index.ts +133 -133
- package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
- package/src/plugins/__tests__/runner.test.ts +409 -409
- package/src/plugins/define.ts +124 -124
- package/src/plugins/examples/dep-check-plugin.ts +80 -80
- package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
- package/src/plugins/examples/sitemap-plugin.ts +65 -65
- package/src/plugins/runner.ts +361 -361
- package/src/plugins/types.ts +368 -368
- package/src/report/index.ts +1 -1
- package/src/resource/__tests__/generator.test.ts +32 -32
- package/src/resource/ddl/__tests__/diff.test.ts +639 -639
- package/src/resource/ddl/__tests__/emit.test.ts +823 -823
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
- package/src/resource/ddl/emit.ts +559 -559
- package/src/resource/ddl/persistence-types.ts +218 -218
- package/src/resource/ddl/type-map.ts +223 -223
- package/src/resource/ddl/types.ts +232 -232
- package/src/resource/generator-repo.ts +630 -630
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.test.ts +96 -96
- package/src/router/fs-routes.ts +6 -6
- package/src/routes/index.ts +74 -74
- package/src/routes/metadata-routes.ts +427 -427
- package/src/routes/types.ts +341 -341
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
- package/src/runtime/__tests__/hdr-client.test.ts +223 -223
- package/src/runtime/__tests__/http-errors.test.ts +117 -117
- package/src/runtime/__tests__/not-found.test.ts +152 -152
- package/src/runtime/adapter.ts +47 -47
- package/src/runtime/boundary.tsx +252 -252
- package/src/runtime/cache.ts +494 -494
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/fast-refresh-runtime.ts +322 -322
- package/src/runtime/handler.ts +65 -65
- package/src/runtime/handlers.ts +300 -300
- package/src/runtime/http-errors.ts +113 -113
- package/src/runtime/image-handler.ts +1 -1
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/middleware.ts +264 -264
- package/src/runtime/not-found.ts +93 -93
- package/src/runtime/openapi-endpoint.ts +236 -236
- package/src/runtime/ppr.ts +74 -74
- package/src/runtime/registry.ts +171 -171
- package/src/runtime/router.ts +105 -105
- package/src/runtime/server.ts +67 -67
- package/src/runtime/shims.ts +48 -48
- package/src/runtime/trace.ts +144 -144
- package/src/scheduler/validate.ts +169 -169
- package/src/seo/index.ts +219 -219
- package/src/seo/integration/ssr.ts +306 -306
- package/src/seo/render/basic.ts +435 -435
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +197 -197
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +127 -127
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +104 -104
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +588 -588
- package/src/slot/validator.ts +39 -39
- package/src/storage/s3/__tests__/s3.test.ts +479 -479
- package/src/storage/s3/index.ts +412 -412
- package/src/testing/__tests__/assertions.test.ts +632 -632
- package/src/testing/__tests__/reporter.test.ts +454 -454
- package/src/testing/assertions.ts +986 -986
- package/src/testing/db.ts +157 -157
- package/src/testing/mocks.ts +203 -203
- package/src/testing/session.ts +190 -190
- package/src/types/branded.ts +56 -56
- package/src/types/index.ts +1 -1
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- package/src/watcher/watcher.ts +18 -18
|
@@ -1,522 +1,522 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @mandujs/core/middleware/rate-limit
|
|
3
|
-
*
|
|
4
|
-
* Sliding-window rate limiter (Phase 6.1) with pluggable stores. Ships two
|
|
5
|
-
* backends out of the box:
|
|
6
|
-
*
|
|
7
|
-
* - {@link createInMemoryStore} — process-local `Map`, zero deps, dies on
|
|
8
|
-
* restart. The right choice for a single-process dev server or a small
|
|
9
|
-
* production deployment where a dropped-on-restart limiter is acceptable.
|
|
10
|
-
* - {@link createSqliteStore} — shared-file SQLite via `@mandujs/core/db`,
|
|
11
|
-
* WAL mode (per RFC 0001 Appendix D.4). Survives restarts and covers
|
|
12
|
-
* same-host multi-process; still NOT a distributed limiter (no
|
|
13
|
-
* multi-host coordination — add Redis in a follow-up phase if needed).
|
|
14
|
-
*
|
|
15
|
-
* ## Algorithm — sliding window via fixed bucket
|
|
16
|
-
*
|
|
17
|
-
* Each key owns a bucket `{ windowStart, count }`. On each hit:
|
|
18
|
-
*
|
|
19
|
-
* - If `now - windowStart >= windowMs` → bucket rolls over: the new window
|
|
20
|
-
* starts at `now` with `count = 1`.
|
|
21
|
-
* - Else → `count++` inside the existing window.
|
|
22
|
-
*
|
|
23
|
-
* The request is `allowed` when `count <= limit`. `resetAt = windowStart +
|
|
24
|
-
* windowMs`; `retryAfterSeconds = ceil((resetAt - now) / 1000)` when blocked.
|
|
25
|
-
*
|
|
26
|
-
* This is *not* a rolling-queue implementation (which would track every hit's
|
|
27
|
-
* timestamp). For typical bursty traffic the observable behaviour is the
|
|
28
|
-
* same — 1/ε the memory, 1/ε the work per hit, and no edge cases at the
|
|
29
|
-
* window boundary. Token-bucket is explicitly deferred to v2 as an
|
|
30
|
-
* alternative algorithm once a real-world use case justifies it.
|
|
31
|
-
*
|
|
32
|
-
* ## Usage
|
|
33
|
-
*
|
|
34
|
-
* ### As middleware
|
|
35
|
-
*
|
|
36
|
-
* ```ts
|
|
37
|
-
* import { rateLimit } from "@mandujs/core/middleware/rate-limit";
|
|
38
|
-
*
|
|
39
|
-
* export default Mandu.filling()
|
|
40
|
-
* .use(rateLimit({ limit: 60, windowMs: 60_000 }))
|
|
41
|
-
* .post((ctx) => ctx.ok({ ok: true }));
|
|
42
|
-
* ```
|
|
43
|
-
*
|
|
44
|
-
* ### As a guard for non-HTTP call sites
|
|
45
|
-
*
|
|
46
|
-
* The auth flows in Phase 5.3 (`verify.send`, `reset.send`) documented their
|
|
47
|
-
* lack of rate limiting explicitly. Wrap them with the guard:
|
|
48
|
-
*
|
|
49
|
-
* ```ts
|
|
50
|
-
* const sendGuard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
51
|
-
* // later, at a POST handler:
|
|
52
|
-
* await sendGuard.enforce(`verify:${userId}`);
|
|
53
|
-
* await verify.send(userId, email);
|
|
54
|
-
* ```
|
|
55
|
-
*
|
|
56
|
-
* Throwing {@link RateLimitError} carries the full {@link RateLimitResult}
|
|
57
|
-
* so the caller can shape its own 429 response.
|
|
58
|
-
*
|
|
59
|
-
* @module middleware/rate-limit
|
|
60
|
-
*/
|
|
61
|
-
|
|
62
|
-
import type { ManduContext } from "../../filling/context";
|
|
63
|
-
|
|
64
|
-
// ─── Public types ───────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
/** Outcome of a single `hit()` on a rate-limit store. */
|
|
67
|
-
export interface RateLimitResult {
|
|
68
|
-
/** Whether the hit stayed within the configured budget. */
|
|
69
|
-
allowed: boolean;
|
|
70
|
-
/**
|
|
71
|
-
* Remaining budget in the current window, after this hit. Always clamped
|
|
72
|
-
* to `>= 0` — a blocked hit reports `0`.
|
|
73
|
-
*/
|
|
74
|
-
remaining: number;
|
|
75
|
-
/** Unix ms at which the current window ends and a fresh one begins. */
|
|
76
|
-
resetAt: number;
|
|
77
|
-
/**
|
|
78
|
-
* `Math.ceil((resetAt - now) / 1000)` when blocked, `0` when allowed.
|
|
79
|
-
* Consumed directly as the `Retry-After` header value on 429 responses.
|
|
80
|
-
*/
|
|
81
|
-
retryAfterSeconds: number;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Pluggable backing store. Each call to {@link hit} atomically records a
|
|
86
|
-
* single request against `key` and returns the resulting state — there is no
|
|
87
|
-
* separate "read then increment" path to avoid race conditions.
|
|
88
|
-
*/
|
|
89
|
-
export interface RateLimitStore {
|
|
90
|
-
/**
|
|
91
|
-
* Record one hit against `key` with `limit` / `windowMs` in effect. MUST
|
|
92
|
-
* be atomic: concurrent callers racing on the same key must observe a
|
|
93
|
-
* strictly-increasing count up to the rollover, never a lost update.
|
|
94
|
-
*/
|
|
95
|
-
hit(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
96
|
-
/**
|
|
97
|
-
* Delete entries whose window is older than `olderThanMs` ago. Returns
|
|
98
|
-
* the number of entries purged. Safe to call on a hot store — the cron
|
|
99
|
-
* schedulers may do so on a fixed tick.
|
|
100
|
-
*/
|
|
101
|
-
gcNow(olderThanMs: number): Promise<number>;
|
|
102
|
-
/** Release any held resources. Optional; safe to omit for in-memory stores. */
|
|
103
|
-
close?(): Promise<void>;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Construction options for {@link rateLimit}. */
|
|
107
|
-
export interface RateLimitMiddlewareOptions {
|
|
108
|
-
/** Maximum hits per window. Required. Must be a positive integer. */
|
|
109
|
-
limit: number;
|
|
110
|
-
/** Window duration in ms. Required. Must be a positive integer. */
|
|
111
|
-
windowMs: number;
|
|
112
|
-
/** Store backend. Default: a fresh in-memory store (per middleware instance). */
|
|
113
|
-
store?: RateLimitStore;
|
|
114
|
-
/**
|
|
115
|
-
* Compute the rate-limit key for the current request. Returning `null`
|
|
116
|
-
* skips limiting for this request — the middleware passes through
|
|
117
|
-
* without touching the store.
|
|
118
|
-
*
|
|
119
|
-
* Default: first entry of `x-forwarded-for` header → `x-real-ip` →
|
|
120
|
-
* `"unknown"`. Production behind a trusted proxy should set XFF; otherwise
|
|
121
|
-
* use a session-user-id key via a custom `keyFn` to limit authenticated
|
|
122
|
-
* traffic per account instead of per (shared) IP.
|
|
123
|
-
*/
|
|
124
|
-
keyFn?: (ctx: ManduContext) => string | null;
|
|
125
|
-
/**
|
|
126
|
-
* Predicate to bypass limiting for specific requests. Return `true` to
|
|
127
|
-
* skip entirely — the store is not consulted, no headers are emitted.
|
|
128
|
-
* Default: never skips.
|
|
129
|
-
*/
|
|
130
|
-
skip?: (ctx: ManduContext) => boolean;
|
|
131
|
-
/**
|
|
132
|
-
* Build the 429 response body on block. Default: JSON
|
|
133
|
-
* `{ error: "rate_limited", retryAfterSeconds }`. Callers can override to
|
|
134
|
-
* match their app's error envelope.
|
|
135
|
-
*/
|
|
136
|
-
handler?: (ctx: ManduContext, result: RateLimitResult) => Response;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Middleware signature matching `csrf.ts` / `session.ts`. */
|
|
140
|
-
export type RateLimitMiddleware = (
|
|
141
|
-
ctx: ManduContext,
|
|
142
|
-
) => Promise<Response | void>;
|
|
143
|
-
|
|
144
|
-
/** Construction options for {@link createRateLimitGuard}. */
|
|
145
|
-
export interface RateLimitGuardOptions {
|
|
146
|
-
limit: number;
|
|
147
|
-
windowMs: number;
|
|
148
|
-
/** Store backend. Default: a fresh in-memory store (per guard instance). */
|
|
149
|
-
store?: RateLimitStore;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Imperative rate-limit handle for non-middleware call sites. Use
|
|
154
|
-
* {@link RateLimitGuard.enforce} to wrap sensitive operations like
|
|
155
|
-
* `verify.send(userId)` that don't live behind HTTP middleware.
|
|
156
|
-
*/
|
|
157
|
-
export interface RateLimitGuard {
|
|
158
|
-
/**
|
|
159
|
-
* Record one hit and return the full result. Never throws — inspect
|
|
160
|
-
* `result.allowed` to branch.
|
|
161
|
-
*/
|
|
162
|
-
check(key: string): Promise<RateLimitResult>;
|
|
163
|
-
/**
|
|
164
|
-
* Record one hit and throw {@link RateLimitError} when blocked. Resolves
|
|
165
|
-
* silently when allowed. Ideal for `await guard.enforce(...)` prologues.
|
|
166
|
-
*/
|
|
167
|
-
enforce(key: string): Promise<void>;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Error thrown by {@link RateLimitGuard.enforce} when a hit is blocked.
|
|
172
|
-
* Carries the full {@link RateLimitResult} so the caller can format the
|
|
173
|
-
* response with accurate `Retry-After` / `X-RateLimit-Reset` information.
|
|
174
|
-
*/
|
|
175
|
-
export class RateLimitError extends Error {
|
|
176
|
-
/** Public so handlers can derive `Retry-After` without downcasting. */
|
|
177
|
-
readonly result: RateLimitResult;
|
|
178
|
-
constructor(result: RateLimitResult, message?: string) {
|
|
179
|
-
super(
|
|
180
|
-
message ??
|
|
181
|
-
`rate_limited: retry after ${result.retryAfterSeconds}s (reset at ${new Date(
|
|
182
|
-
result.resetAt,
|
|
183
|
-
).toISOString()})`,
|
|
184
|
-
);
|
|
185
|
-
this.name = "RateLimitError";
|
|
186
|
-
this.result = result;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
const DEFAULT_KEY_UNKNOWN = "unknown";
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Clock injector. Kept module-scoped (not per-store) so tests can freeze time
|
|
196
|
-
* across both the middleware and any stores it calls during a single
|
|
197
|
-
* scenario. Override via {@link _setClockForTests}; production callers never
|
|
198
|
-
* touch this.
|
|
199
|
-
*
|
|
200
|
-
* @internal
|
|
201
|
-
*/
|
|
202
|
-
let __now: () => number = () => Date.now();
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Replace the module-level clock. Test-only hook; not exported from the
|
|
206
|
-
* package surface.
|
|
207
|
-
*
|
|
208
|
-
* @internal
|
|
209
|
-
*/
|
|
210
|
-
export function _setClockForTests(fn: (() => number) | null): void {
|
|
211
|
-
__now = fn ?? (() => Date.now());
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// ─── Key derivation ─────────────────────────────────────────────────────────
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Default key derivation. Reads the first hop of `x-forwarded-for` (the
|
|
218
|
-
* client IP when a trusted reverse proxy is in front), falling back to
|
|
219
|
-
* `x-real-ip`, and finally a literal `"unknown"` bucket.
|
|
220
|
-
*
|
|
221
|
-
* The `"unknown"` fallback is intentionally a single shared bucket: when the
|
|
222
|
-
* edge has not forwarded either header, we cannot tell callers apart, and a
|
|
223
|
-
* hostile client could otherwise bypass the limit by simply stripping the
|
|
224
|
-
* header. Shared throttling keeps the limiter safe but may be harsh on
|
|
225
|
-
* no-proxy dev setups — set a custom `keyFn` that uses a session id, API
|
|
226
|
-
* key, or user id for production traffic.
|
|
227
|
-
*/
|
|
228
|
-
function defaultKeyFn(ctx: ManduContext): string {
|
|
229
|
-
const xff = ctx.request.headers.get("x-forwarded-for");
|
|
230
|
-
if (typeof xff === "string" && xff.length > 0) {
|
|
231
|
-
// XFF is a comma-separated chain; the client is the first entry.
|
|
232
|
-
const first = xff.split(",")[0]?.trim();
|
|
233
|
-
if (first && first.length > 0) return first;
|
|
234
|
-
}
|
|
235
|
-
const realIp = ctx.request.headers.get("x-real-ip");
|
|
236
|
-
if (typeof realIp === "string" && realIp.length > 0) return realIp.trim();
|
|
237
|
-
return DEFAULT_KEY_UNKNOWN;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// ─── Middleware factory ─────────────────────────────────────────────────────
|
|
241
|
-
|
|
242
|
-
function assertPositiveInt(name: string, value: number): void {
|
|
243
|
-
if (
|
|
244
|
-
typeof value !== "number" ||
|
|
245
|
-
!Number.isFinite(value) ||
|
|
246
|
-
value <= 0 ||
|
|
247
|
-
Math.floor(value) !== value
|
|
248
|
-
) {
|
|
249
|
-
throw new TypeError(
|
|
250
|
-
`[@mandujs/core/middleware/rate-limit] '${name}' must be a positive integer; got ${String(
|
|
251
|
-
value,
|
|
252
|
-
)}.`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Sliding-window rate-limit middleware.
|
|
259
|
-
*
|
|
260
|
-
* Behaviour:
|
|
261
|
-
* 1. If `skip(ctx)` returns `true`, the middleware returns void immediately
|
|
262
|
-
* — no store access, no headers.
|
|
263
|
-
* 2. Otherwise, `keyFn(ctx)` produces a key. `null` skips the store (useful
|
|
264
|
-
* for "only limit authenticated traffic" policies).
|
|
265
|
-
* 3. `store.hit(key, limit, windowMs)` records and evaluates.
|
|
266
|
-
* 4. On block: returns 429 with `Retry-After` + `X-RateLimit-*` headers and
|
|
267
|
-
* a JSON body (or whatever `handler` returns). The caller's handler
|
|
268
|
-
* pipeline is short-circuited.
|
|
269
|
-
* 5. On allow: returns void. No response mutation is performed here — the
|
|
270
|
-
* middleware surface has no afterHandle hook on this variant. Callers
|
|
271
|
-
* who want `X-RateLimit-*` headers on allowed responses should use
|
|
272
|
-
* {@link rateLimitPlugin} (exposes beforeHandle + afterHandle) instead.
|
|
273
|
-
*/
|
|
274
|
-
export function rateLimit(
|
|
275
|
-
options: RateLimitMiddlewareOptions,
|
|
276
|
-
): RateLimitMiddleware {
|
|
277
|
-
if (!options || typeof options !== "object") {
|
|
278
|
-
throw new TypeError(
|
|
279
|
-
"[@mandujs/core/middleware/rate-limit] rateLimit: options object required.",
|
|
280
|
-
);
|
|
281
|
-
}
|
|
282
|
-
assertPositiveInt("limit", options.limit);
|
|
283
|
-
assertPositiveInt("windowMs", options.windowMs);
|
|
284
|
-
|
|
285
|
-
const limit = options.limit;
|
|
286
|
-
const windowMs = options.windowMs;
|
|
287
|
-
const store = options.store ?? createInMemoryStore();
|
|
288
|
-
const keyFn = options.keyFn ?? defaultKeyFn;
|
|
289
|
-
const skip = options.skip;
|
|
290
|
-
const handler = options.handler ?? defaultBlockedHandler;
|
|
291
|
-
|
|
292
|
-
return async (ctx: ManduContext): Promise<Response | void> => {
|
|
293
|
-
if (skip && skip(ctx)) {
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
const key = keyFn(ctx);
|
|
297
|
-
if (key === null) {
|
|
298
|
-
// Caller's key function explicitly opts out for this request.
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const result = await store.hit(key, limit, windowMs);
|
|
303
|
-
|
|
304
|
-
if (!result.allowed) {
|
|
305
|
-
const res = handler(ctx, result);
|
|
306
|
-
// The caller's `handler` may return a pre-built Response that already
|
|
307
|
-
// carries rate-limit headers. We only stamp them when absent so a
|
|
308
|
-
// custom handler that wants to hide the Retry-After (rare) can do so.
|
|
309
|
-
return applyRateLimitHeaders(res, limit, result);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Allowed: pass through. Callers who need `X-RateLimit-*` headers on
|
|
313
|
-
// successful responses should layer {@link rateLimitPlugin} on top of
|
|
314
|
-
// the filling chain — this plain-middleware variant cannot mutate the
|
|
315
|
-
// outgoing Response without an afterHandle hook.
|
|
316
|
-
return;
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Default 429 response body. Intentionally small — exposes only what a
|
|
322
|
-
* well-behaved client legitimately needs. `resetAt` is Unix-ms so clients
|
|
323
|
-
* don't have to negotiate timezone interpretation.
|
|
324
|
-
*/
|
|
325
|
-
function defaultBlockedHandler(
|
|
326
|
-
_ctx: ManduContext,
|
|
327
|
-
result: RateLimitResult,
|
|
328
|
-
): Response {
|
|
329
|
-
return Response.json(
|
|
330
|
-
{
|
|
331
|
-
error: "rate_limited",
|
|
332
|
-
retryAfterSeconds: result.retryAfterSeconds,
|
|
333
|
-
resetAt: result.resetAt,
|
|
334
|
-
},
|
|
335
|
-
{ status: 429 },
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* Stamp `Retry-After` + `X-RateLimit-*` headers on a blocked response. The
|
|
341
|
-
* `X-RateLimit-Reset` value is Unix-seconds (not ms) to match the informal
|
|
342
|
-
* convention used by GitHub / Twitter / Stripe — see GitHub's API docs.
|
|
343
|
-
*
|
|
344
|
-
* Preserves any header the caller's handler has already set (checked with
|
|
345
|
-
* `Headers.has`) so custom handlers can override values at will.
|
|
346
|
-
*/
|
|
347
|
-
function applyRateLimitHeaders(
|
|
348
|
-
response: Response,
|
|
349
|
-
limit: number,
|
|
350
|
-
result: RateLimitResult,
|
|
351
|
-
): Response {
|
|
352
|
-
const headers = new Headers(response.headers);
|
|
353
|
-
if (!headers.has("Retry-After")) {
|
|
354
|
-
headers.set("Retry-After", String(result.retryAfterSeconds));
|
|
355
|
-
}
|
|
356
|
-
if (!headers.has("X-RateLimit-Limit")) {
|
|
357
|
-
headers.set("X-RateLimit-Limit", String(limit));
|
|
358
|
-
}
|
|
359
|
-
if (!headers.has("X-RateLimit-Remaining")) {
|
|
360
|
-
headers.set("X-RateLimit-Remaining", String(result.remaining));
|
|
361
|
-
}
|
|
362
|
-
if (!headers.has("X-RateLimit-Reset")) {
|
|
363
|
-
headers.set(
|
|
364
|
-
"X-RateLimit-Reset",
|
|
365
|
-
String(Math.floor(result.resetAt / 1000)),
|
|
366
|
-
);
|
|
367
|
-
}
|
|
368
|
-
return new Response(response.body, {
|
|
369
|
-
status: response.status,
|
|
370
|
-
statusText: response.statusText,
|
|
371
|
-
headers,
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// ─── Guard (imperative) ─────────────────────────────────────────────────────
|
|
376
|
-
|
|
377
|
-
/**
|
|
378
|
-
* Construct an imperative rate-limit guard. Use when the protected operation
|
|
379
|
-
* is NOT an HTTP handler — e.g. an outbound email from a server-side action.
|
|
380
|
-
*
|
|
381
|
-
* Every guard owns its own store by default, so two guards with the same
|
|
382
|
-
* `{ limit, windowMs }` are independent. Share a store explicitly when two
|
|
383
|
-
* guards must consume the same budget.
|
|
384
|
-
*/
|
|
385
|
-
export function createRateLimitGuard(
|
|
386
|
-
options: RateLimitGuardOptions,
|
|
387
|
-
): RateLimitGuard {
|
|
388
|
-
if (!options || typeof options !== "object") {
|
|
389
|
-
throw new TypeError(
|
|
390
|
-
"[@mandujs/core/middleware/rate-limit] createRateLimitGuard: options object required.",
|
|
391
|
-
);
|
|
392
|
-
}
|
|
393
|
-
assertPositiveInt("limit", options.limit);
|
|
394
|
-
assertPositiveInt("windowMs", options.windowMs);
|
|
395
|
-
|
|
396
|
-
const limit = options.limit;
|
|
397
|
-
const windowMs = options.windowMs;
|
|
398
|
-
const store = options.store ?? createInMemoryStore();
|
|
399
|
-
|
|
400
|
-
return {
|
|
401
|
-
async check(key: string): Promise<RateLimitResult> {
|
|
402
|
-
if (typeof key !== "string" || key.length === 0) {
|
|
403
|
-
throw new TypeError(
|
|
404
|
-
"[@mandujs/core/middleware/rate-limit] check: key must be a non-empty string.",
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
return await store.hit(key, limit, windowMs);
|
|
408
|
-
},
|
|
409
|
-
async enforce(key: string): Promise<void> {
|
|
410
|
-
if (typeof key !== "string" || key.length === 0) {
|
|
411
|
-
throw new TypeError(
|
|
412
|
-
"[@mandujs/core/middleware/rate-limit] enforce: key must be a non-empty string.",
|
|
413
|
-
);
|
|
414
|
-
}
|
|
415
|
-
const result = await store.hit(key, limit, windowMs);
|
|
416
|
-
if (!result.allowed) {
|
|
417
|
-
throw new RateLimitError(result);
|
|
418
|
-
}
|
|
419
|
-
},
|
|
420
|
-
};
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
// ─── In-memory store ────────────────────────────────────────────────────────
|
|
424
|
-
|
|
425
|
-
interface Bucket {
|
|
426
|
-
windowStart: number;
|
|
427
|
-
count: number;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Process-local in-memory store. No external dependencies, no persistence
|
|
432
|
-
* across restarts. Concurrency-safe within a single event loop (Map reads
|
|
433
|
-
* and writes are not preempted mid-operation in JS); no locking needed.
|
|
434
|
-
*
|
|
435
|
-
* Not safe across processes or hosts — use {@link createSqliteStore} for
|
|
436
|
-
* same-host multi-process, or a distributed store (future Redis backend)
|
|
437
|
-
* for multi-host.
|
|
438
|
-
*/
|
|
439
|
-
export function createInMemoryStore(): RateLimitStore {
|
|
440
|
-
const buckets = new Map<string, Bucket>();
|
|
441
|
-
let closed = false;
|
|
442
|
-
|
|
443
|
-
return {
|
|
444
|
-
async hit(
|
|
445
|
-
key: string,
|
|
446
|
-
limit: number,
|
|
447
|
-
windowMs: number,
|
|
448
|
-
): Promise<RateLimitResult> {
|
|
449
|
-
if (closed) {
|
|
450
|
-
throw new Error(
|
|
451
|
-
"[@mandujs/core/middleware/rate-limit] in-memory store is closed.",
|
|
452
|
-
);
|
|
453
|
-
}
|
|
454
|
-
const now = __now();
|
|
455
|
-
const existing = buckets.get(key);
|
|
456
|
-
|
|
457
|
-
let bucket: Bucket;
|
|
458
|
-
if (!existing || now - existing.windowStart >= windowMs) {
|
|
459
|
-
// Window rollover (or first hit). Start a fresh window anchored at
|
|
460
|
-
// `now` — the simplest model that still reports a precise resetAt.
|
|
461
|
-
bucket = { windowStart: now, count: 1 };
|
|
462
|
-
} else {
|
|
463
|
-
// Still inside the current window — increment.
|
|
464
|
-
bucket = { windowStart: existing.windowStart, count: existing.count + 1 };
|
|
465
|
-
}
|
|
466
|
-
buckets.set(key, bucket);
|
|
467
|
-
|
|
468
|
-
const resetAt = bucket.windowStart + windowMs;
|
|
469
|
-
const allowed = bucket.count <= limit;
|
|
470
|
-
// `remaining` reports post-hit budget; clamped at 0 so a blocked hit
|
|
471
|
-
// never reports negative remaining (would confuse clients rendering a
|
|
472
|
-
// progress indicator).
|
|
473
|
-
const remaining = Math.max(0, limit - bucket.count);
|
|
474
|
-
const retryAfterSeconds = allowed
|
|
475
|
-
? 0
|
|
476
|
-
: Math.max(1, Math.ceil((resetAt - now) / 1000));
|
|
477
|
-
|
|
478
|
-
return { allowed, remaining, resetAt, retryAfterSeconds };
|
|
479
|
-
},
|
|
480
|
-
|
|
481
|
-
async gcNow(olderThanMs: number): Promise<number> {
|
|
482
|
-
if (closed) return 0;
|
|
483
|
-
if (typeof olderThanMs !== "number" || olderThanMs < 0) {
|
|
484
|
-
throw new TypeError(
|
|
485
|
-
"[@mandujs/core/middleware/rate-limit] gcNow: olderThanMs must be a non-negative number.",
|
|
486
|
-
);
|
|
487
|
-
}
|
|
488
|
-
const now = __now();
|
|
489
|
-
let deleted = 0;
|
|
490
|
-
// Iterating + deleting from a Map during traversal is safe per the
|
|
491
|
-
// ES spec — entries visited before deletion yield, already-visited
|
|
492
|
-
// entries are skipped. We still collect keys into a throwaway array
|
|
493
|
-
// to keep the hot-path clean across engines.
|
|
494
|
-
const stale: string[] = [];
|
|
495
|
-
for (const [key, bucket] of buckets) {
|
|
496
|
-
if (now - bucket.windowStart > olderThanMs) {
|
|
497
|
-
stale.push(key);
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
for (const key of stale) {
|
|
501
|
-
buckets.delete(key);
|
|
502
|
-
deleted++;
|
|
503
|
-
}
|
|
504
|
-
return deleted;
|
|
505
|
-
},
|
|
506
|
-
|
|
507
|
-
async close(): Promise<void> {
|
|
508
|
-
if (closed) return;
|
|
509
|
-
closed = true;
|
|
510
|
-
buckets.clear();
|
|
511
|
-
},
|
|
512
|
-
};
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
// ─── SQLite store (re-export) ───────────────────────────────────────────────
|
|
516
|
-
|
|
517
|
-
// The SQLite store lives in its own module so callers who never need it
|
|
518
|
-
// don't pay the import cost of `@mandujs/core/db`.
|
|
519
|
-
export {
|
|
520
|
-
createSqliteStore,
|
|
521
|
-
type SqliteRateLimitStoreOptions,
|
|
522
|
-
} from "./sqlite-store";
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/middleware/rate-limit
|
|
3
|
+
*
|
|
4
|
+
* Sliding-window rate limiter (Phase 6.1) with pluggable stores. Ships two
|
|
5
|
+
* backends out of the box:
|
|
6
|
+
*
|
|
7
|
+
* - {@link createInMemoryStore} — process-local `Map`, zero deps, dies on
|
|
8
|
+
* restart. The right choice for a single-process dev server or a small
|
|
9
|
+
* production deployment where a dropped-on-restart limiter is acceptable.
|
|
10
|
+
* - {@link createSqliteStore} — shared-file SQLite via `@mandujs/core/db`,
|
|
11
|
+
* WAL mode (per RFC 0001 Appendix D.4). Survives restarts and covers
|
|
12
|
+
* same-host multi-process; still NOT a distributed limiter (no
|
|
13
|
+
* multi-host coordination — add Redis in a follow-up phase if needed).
|
|
14
|
+
*
|
|
15
|
+
* ## Algorithm — sliding window via fixed bucket
|
|
16
|
+
*
|
|
17
|
+
* Each key owns a bucket `{ windowStart, count }`. On each hit:
|
|
18
|
+
*
|
|
19
|
+
* - If `now - windowStart >= windowMs` → bucket rolls over: the new window
|
|
20
|
+
* starts at `now` with `count = 1`.
|
|
21
|
+
* - Else → `count++` inside the existing window.
|
|
22
|
+
*
|
|
23
|
+
* The request is `allowed` when `count <= limit`. `resetAt = windowStart +
|
|
24
|
+
* windowMs`; `retryAfterSeconds = ceil((resetAt - now) / 1000)` when blocked.
|
|
25
|
+
*
|
|
26
|
+
* This is *not* a rolling-queue implementation (which would track every hit's
|
|
27
|
+
* timestamp). For typical bursty traffic the observable behaviour is the
|
|
28
|
+
* same — 1/ε the memory, 1/ε the work per hit, and no edge cases at the
|
|
29
|
+
* window boundary. Token-bucket is explicitly deferred to v2 as an
|
|
30
|
+
* alternative algorithm once a real-world use case justifies it.
|
|
31
|
+
*
|
|
32
|
+
* ## Usage
|
|
33
|
+
*
|
|
34
|
+
* ### As middleware
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* import { rateLimit } from "@mandujs/core/middleware/rate-limit";
|
|
38
|
+
*
|
|
39
|
+
* export default Mandu.filling()
|
|
40
|
+
* .use(rateLimit({ limit: 60, windowMs: 60_000 }))
|
|
41
|
+
* .post((ctx) => ctx.ok({ ok: true }));
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* ### As a guard for non-HTTP call sites
|
|
45
|
+
*
|
|
46
|
+
* The auth flows in Phase 5.3 (`verify.send`, `reset.send`) documented their
|
|
47
|
+
* lack of rate limiting explicitly. Wrap them with the guard:
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* const sendGuard = createRateLimitGuard({ limit: 1, windowMs: 60_000 });
|
|
51
|
+
* // later, at a POST handler:
|
|
52
|
+
* await sendGuard.enforce(`verify:${userId}`);
|
|
53
|
+
* await verify.send(userId, email);
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* Throwing {@link RateLimitError} carries the full {@link RateLimitResult}
|
|
57
|
+
* so the caller can shape its own 429 response.
|
|
58
|
+
*
|
|
59
|
+
* @module middleware/rate-limit
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import type { ManduContext } from "../../filling/context";
|
|
63
|
+
|
|
64
|
+
// ─── Public types ───────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Outcome of a single `hit()` on a rate-limit store. */
|
|
67
|
+
export interface RateLimitResult {
|
|
68
|
+
/** Whether the hit stayed within the configured budget. */
|
|
69
|
+
allowed: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Remaining budget in the current window, after this hit. Always clamped
|
|
72
|
+
* to `>= 0` — a blocked hit reports `0`.
|
|
73
|
+
*/
|
|
74
|
+
remaining: number;
|
|
75
|
+
/** Unix ms at which the current window ends and a fresh one begins. */
|
|
76
|
+
resetAt: number;
|
|
77
|
+
/**
|
|
78
|
+
* `Math.ceil((resetAt - now) / 1000)` when blocked, `0` when allowed.
|
|
79
|
+
* Consumed directly as the `Retry-After` header value on 429 responses.
|
|
80
|
+
*/
|
|
81
|
+
retryAfterSeconds: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Pluggable backing store. Each call to {@link hit} atomically records a
|
|
86
|
+
* single request against `key` and returns the resulting state — there is no
|
|
87
|
+
* separate "read then increment" path to avoid race conditions.
|
|
88
|
+
*/
|
|
89
|
+
export interface RateLimitStore {
|
|
90
|
+
/**
|
|
91
|
+
* Record one hit against `key` with `limit` / `windowMs` in effect. MUST
|
|
92
|
+
* be atomic: concurrent callers racing on the same key must observe a
|
|
93
|
+
* strictly-increasing count up to the rollover, never a lost update.
|
|
94
|
+
*/
|
|
95
|
+
hit(key: string, limit: number, windowMs: number): Promise<RateLimitResult>;
|
|
96
|
+
/**
|
|
97
|
+
* Delete entries whose window is older than `olderThanMs` ago. Returns
|
|
98
|
+
* the number of entries purged. Safe to call on a hot store — the cron
|
|
99
|
+
* schedulers may do so on a fixed tick.
|
|
100
|
+
*/
|
|
101
|
+
gcNow(olderThanMs: number): Promise<number>;
|
|
102
|
+
/** Release any held resources. Optional; safe to omit for in-memory stores. */
|
|
103
|
+
close?(): Promise<void>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Construction options for {@link rateLimit}. */
|
|
107
|
+
export interface RateLimitMiddlewareOptions {
|
|
108
|
+
/** Maximum hits per window. Required. Must be a positive integer. */
|
|
109
|
+
limit: number;
|
|
110
|
+
/** Window duration in ms. Required. Must be a positive integer. */
|
|
111
|
+
windowMs: number;
|
|
112
|
+
/** Store backend. Default: a fresh in-memory store (per middleware instance). */
|
|
113
|
+
store?: RateLimitStore;
|
|
114
|
+
/**
|
|
115
|
+
* Compute the rate-limit key for the current request. Returning `null`
|
|
116
|
+
* skips limiting for this request — the middleware passes through
|
|
117
|
+
* without touching the store.
|
|
118
|
+
*
|
|
119
|
+
* Default: first entry of `x-forwarded-for` header → `x-real-ip` →
|
|
120
|
+
* `"unknown"`. Production behind a trusted proxy should set XFF; otherwise
|
|
121
|
+
* use a session-user-id key via a custom `keyFn` to limit authenticated
|
|
122
|
+
* traffic per account instead of per (shared) IP.
|
|
123
|
+
*/
|
|
124
|
+
keyFn?: (ctx: ManduContext) => string | null;
|
|
125
|
+
/**
|
|
126
|
+
* Predicate to bypass limiting for specific requests. Return `true` to
|
|
127
|
+
* skip entirely — the store is not consulted, no headers are emitted.
|
|
128
|
+
* Default: never skips.
|
|
129
|
+
*/
|
|
130
|
+
skip?: (ctx: ManduContext) => boolean;
|
|
131
|
+
/**
|
|
132
|
+
* Build the 429 response body on block. Default: JSON
|
|
133
|
+
* `{ error: "rate_limited", retryAfterSeconds }`. Callers can override to
|
|
134
|
+
* match their app's error envelope.
|
|
135
|
+
*/
|
|
136
|
+
handler?: (ctx: ManduContext, result: RateLimitResult) => Response;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Middleware signature matching `csrf.ts` / `session.ts`. */
|
|
140
|
+
export type RateLimitMiddleware = (
|
|
141
|
+
ctx: ManduContext,
|
|
142
|
+
) => Promise<Response | void>;
|
|
143
|
+
|
|
144
|
+
/** Construction options for {@link createRateLimitGuard}. */
|
|
145
|
+
export interface RateLimitGuardOptions {
|
|
146
|
+
limit: number;
|
|
147
|
+
windowMs: number;
|
|
148
|
+
/** Store backend. Default: a fresh in-memory store (per guard instance). */
|
|
149
|
+
store?: RateLimitStore;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Imperative rate-limit handle for non-middleware call sites. Use
|
|
154
|
+
* {@link RateLimitGuard.enforce} to wrap sensitive operations like
|
|
155
|
+
* `verify.send(userId)` that don't live behind HTTP middleware.
|
|
156
|
+
*/
|
|
157
|
+
export interface RateLimitGuard {
|
|
158
|
+
/**
|
|
159
|
+
* Record one hit and return the full result. Never throws — inspect
|
|
160
|
+
* `result.allowed` to branch.
|
|
161
|
+
*/
|
|
162
|
+
check(key: string): Promise<RateLimitResult>;
|
|
163
|
+
/**
|
|
164
|
+
* Record one hit and throw {@link RateLimitError} when blocked. Resolves
|
|
165
|
+
* silently when allowed. Ideal for `await guard.enforce(...)` prologues.
|
|
166
|
+
*/
|
|
167
|
+
enforce(key: string): Promise<void>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Error thrown by {@link RateLimitGuard.enforce} when a hit is blocked.
|
|
172
|
+
* Carries the full {@link RateLimitResult} so the caller can format the
|
|
173
|
+
* response with accurate `Retry-After` / `X-RateLimit-Reset` information.
|
|
174
|
+
*/
|
|
175
|
+
export class RateLimitError extends Error {
|
|
176
|
+
/** Public so handlers can derive `Retry-After` without downcasting. */
|
|
177
|
+
readonly result: RateLimitResult;
|
|
178
|
+
constructor(result: RateLimitResult, message?: string) {
|
|
179
|
+
super(
|
|
180
|
+
message ??
|
|
181
|
+
`rate_limited: retry after ${result.retryAfterSeconds}s (reset at ${new Date(
|
|
182
|
+
result.resetAt,
|
|
183
|
+
).toISOString()})`,
|
|
184
|
+
);
|
|
185
|
+
this.name = "RateLimitError";
|
|
186
|
+
this.result = result;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
const DEFAULT_KEY_UNKNOWN = "unknown";
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Clock injector. Kept module-scoped (not per-store) so tests can freeze time
|
|
196
|
+
* across both the middleware and any stores it calls during a single
|
|
197
|
+
* scenario. Override via {@link _setClockForTests}; production callers never
|
|
198
|
+
* touch this.
|
|
199
|
+
*
|
|
200
|
+
* @internal
|
|
201
|
+
*/
|
|
202
|
+
let __now: () => number = () => Date.now();
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Replace the module-level clock. Test-only hook; not exported from the
|
|
206
|
+
* package surface.
|
|
207
|
+
*
|
|
208
|
+
* @internal
|
|
209
|
+
*/
|
|
210
|
+
export function _setClockForTests(fn: (() => number) | null): void {
|
|
211
|
+
__now = fn ?? (() => Date.now());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Key derivation ─────────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Default key derivation. Reads the first hop of `x-forwarded-for` (the
|
|
218
|
+
* client IP when a trusted reverse proxy is in front), falling back to
|
|
219
|
+
* `x-real-ip`, and finally a literal `"unknown"` bucket.
|
|
220
|
+
*
|
|
221
|
+
* The `"unknown"` fallback is intentionally a single shared bucket: when the
|
|
222
|
+
* edge has not forwarded either header, we cannot tell callers apart, and a
|
|
223
|
+
* hostile client could otherwise bypass the limit by simply stripping the
|
|
224
|
+
* header. Shared throttling keeps the limiter safe but may be harsh on
|
|
225
|
+
* no-proxy dev setups — set a custom `keyFn` that uses a session id, API
|
|
226
|
+
* key, or user id for production traffic.
|
|
227
|
+
*/
|
|
228
|
+
function defaultKeyFn(ctx: ManduContext): string {
|
|
229
|
+
const xff = ctx.request.headers.get("x-forwarded-for");
|
|
230
|
+
if (typeof xff === "string" && xff.length > 0) {
|
|
231
|
+
// XFF is a comma-separated chain; the client is the first entry.
|
|
232
|
+
const first = xff.split(",")[0]?.trim();
|
|
233
|
+
if (first && first.length > 0) return first;
|
|
234
|
+
}
|
|
235
|
+
const realIp = ctx.request.headers.get("x-real-ip");
|
|
236
|
+
if (typeof realIp === "string" && realIp.length > 0) return realIp.trim();
|
|
237
|
+
return DEFAULT_KEY_UNKNOWN;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ─── Middleware factory ─────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
function assertPositiveInt(name: string, value: number): void {
|
|
243
|
+
if (
|
|
244
|
+
typeof value !== "number" ||
|
|
245
|
+
!Number.isFinite(value) ||
|
|
246
|
+
value <= 0 ||
|
|
247
|
+
Math.floor(value) !== value
|
|
248
|
+
) {
|
|
249
|
+
throw new TypeError(
|
|
250
|
+
`[@mandujs/core/middleware/rate-limit] '${name}' must be a positive integer; got ${String(
|
|
251
|
+
value,
|
|
252
|
+
)}.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Sliding-window rate-limit middleware.
|
|
259
|
+
*
|
|
260
|
+
* Behaviour:
|
|
261
|
+
* 1. If `skip(ctx)` returns `true`, the middleware returns void immediately
|
|
262
|
+
* — no store access, no headers.
|
|
263
|
+
* 2. Otherwise, `keyFn(ctx)` produces a key. `null` skips the store (useful
|
|
264
|
+
* for "only limit authenticated traffic" policies).
|
|
265
|
+
* 3. `store.hit(key, limit, windowMs)` records and evaluates.
|
|
266
|
+
* 4. On block: returns 429 with `Retry-After` + `X-RateLimit-*` headers and
|
|
267
|
+
* a JSON body (or whatever `handler` returns). The caller's handler
|
|
268
|
+
* pipeline is short-circuited.
|
|
269
|
+
* 5. On allow: returns void. No response mutation is performed here — the
|
|
270
|
+
* middleware surface has no afterHandle hook on this variant. Callers
|
|
271
|
+
* who want `X-RateLimit-*` headers on allowed responses should use
|
|
272
|
+
* {@link rateLimitPlugin} (exposes beforeHandle + afterHandle) instead.
|
|
273
|
+
*/
|
|
274
|
+
export function rateLimit(
|
|
275
|
+
options: RateLimitMiddlewareOptions,
|
|
276
|
+
): RateLimitMiddleware {
|
|
277
|
+
if (!options || typeof options !== "object") {
|
|
278
|
+
throw new TypeError(
|
|
279
|
+
"[@mandujs/core/middleware/rate-limit] rateLimit: options object required.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
assertPositiveInt("limit", options.limit);
|
|
283
|
+
assertPositiveInt("windowMs", options.windowMs);
|
|
284
|
+
|
|
285
|
+
const limit = options.limit;
|
|
286
|
+
const windowMs = options.windowMs;
|
|
287
|
+
const store = options.store ?? createInMemoryStore();
|
|
288
|
+
const keyFn = options.keyFn ?? defaultKeyFn;
|
|
289
|
+
const skip = options.skip;
|
|
290
|
+
const handler = options.handler ?? defaultBlockedHandler;
|
|
291
|
+
|
|
292
|
+
return async (ctx: ManduContext): Promise<Response | void> => {
|
|
293
|
+
if (skip && skip(ctx)) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const key = keyFn(ctx);
|
|
297
|
+
if (key === null) {
|
|
298
|
+
// Caller's key function explicitly opts out for this request.
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const result = await store.hit(key, limit, windowMs);
|
|
303
|
+
|
|
304
|
+
if (!result.allowed) {
|
|
305
|
+
const res = handler(ctx, result);
|
|
306
|
+
// The caller's `handler` may return a pre-built Response that already
|
|
307
|
+
// carries rate-limit headers. We only stamp them when absent so a
|
|
308
|
+
// custom handler that wants to hide the Retry-After (rare) can do so.
|
|
309
|
+
return applyRateLimitHeaders(res, limit, result);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Allowed: pass through. Callers who need `X-RateLimit-*` headers on
|
|
313
|
+
// successful responses should layer {@link rateLimitPlugin} on top of
|
|
314
|
+
// the filling chain — this plain-middleware variant cannot mutate the
|
|
315
|
+
// outgoing Response without an afterHandle hook.
|
|
316
|
+
return;
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Default 429 response body. Intentionally small — exposes only what a
|
|
322
|
+
* well-behaved client legitimately needs. `resetAt` is Unix-ms so clients
|
|
323
|
+
* don't have to negotiate timezone interpretation.
|
|
324
|
+
*/
|
|
325
|
+
function defaultBlockedHandler(
|
|
326
|
+
_ctx: ManduContext,
|
|
327
|
+
result: RateLimitResult,
|
|
328
|
+
): Response {
|
|
329
|
+
return Response.json(
|
|
330
|
+
{
|
|
331
|
+
error: "rate_limited",
|
|
332
|
+
retryAfterSeconds: result.retryAfterSeconds,
|
|
333
|
+
resetAt: result.resetAt,
|
|
334
|
+
},
|
|
335
|
+
{ status: 429 },
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Stamp `Retry-After` + `X-RateLimit-*` headers on a blocked response. The
|
|
341
|
+
* `X-RateLimit-Reset` value is Unix-seconds (not ms) to match the informal
|
|
342
|
+
* convention used by GitHub / Twitter / Stripe — see GitHub's API docs.
|
|
343
|
+
*
|
|
344
|
+
* Preserves any header the caller's handler has already set (checked with
|
|
345
|
+
* `Headers.has`) so custom handlers can override values at will.
|
|
346
|
+
*/
|
|
347
|
+
function applyRateLimitHeaders(
|
|
348
|
+
response: Response,
|
|
349
|
+
limit: number,
|
|
350
|
+
result: RateLimitResult,
|
|
351
|
+
): Response {
|
|
352
|
+
const headers = new Headers(response.headers);
|
|
353
|
+
if (!headers.has("Retry-After")) {
|
|
354
|
+
headers.set("Retry-After", String(result.retryAfterSeconds));
|
|
355
|
+
}
|
|
356
|
+
if (!headers.has("X-RateLimit-Limit")) {
|
|
357
|
+
headers.set("X-RateLimit-Limit", String(limit));
|
|
358
|
+
}
|
|
359
|
+
if (!headers.has("X-RateLimit-Remaining")) {
|
|
360
|
+
headers.set("X-RateLimit-Remaining", String(result.remaining));
|
|
361
|
+
}
|
|
362
|
+
if (!headers.has("X-RateLimit-Reset")) {
|
|
363
|
+
headers.set(
|
|
364
|
+
"X-RateLimit-Reset",
|
|
365
|
+
String(Math.floor(result.resetAt / 1000)),
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return new Response(response.body, {
|
|
369
|
+
status: response.status,
|
|
370
|
+
statusText: response.statusText,
|
|
371
|
+
headers,
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ─── Guard (imperative) ─────────────────────────────────────────────────────
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Construct an imperative rate-limit guard. Use when the protected operation
|
|
379
|
+
* is NOT an HTTP handler — e.g. an outbound email from a server-side action.
|
|
380
|
+
*
|
|
381
|
+
* Every guard owns its own store by default, so two guards with the same
|
|
382
|
+
* `{ limit, windowMs }` are independent. Share a store explicitly when two
|
|
383
|
+
* guards must consume the same budget.
|
|
384
|
+
*/
|
|
385
|
+
export function createRateLimitGuard(
|
|
386
|
+
options: RateLimitGuardOptions,
|
|
387
|
+
): RateLimitGuard {
|
|
388
|
+
if (!options || typeof options !== "object") {
|
|
389
|
+
throw new TypeError(
|
|
390
|
+
"[@mandujs/core/middleware/rate-limit] createRateLimitGuard: options object required.",
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
assertPositiveInt("limit", options.limit);
|
|
394
|
+
assertPositiveInt("windowMs", options.windowMs);
|
|
395
|
+
|
|
396
|
+
const limit = options.limit;
|
|
397
|
+
const windowMs = options.windowMs;
|
|
398
|
+
const store = options.store ?? createInMemoryStore();
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
async check(key: string): Promise<RateLimitResult> {
|
|
402
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
403
|
+
throw new TypeError(
|
|
404
|
+
"[@mandujs/core/middleware/rate-limit] check: key must be a non-empty string.",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return await store.hit(key, limit, windowMs);
|
|
408
|
+
},
|
|
409
|
+
async enforce(key: string): Promise<void> {
|
|
410
|
+
if (typeof key !== "string" || key.length === 0) {
|
|
411
|
+
throw new TypeError(
|
|
412
|
+
"[@mandujs/core/middleware/rate-limit] enforce: key must be a non-empty string.",
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const result = await store.hit(key, limit, windowMs);
|
|
416
|
+
if (!result.allowed) {
|
|
417
|
+
throw new RateLimitError(result);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ─── In-memory store ────────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
interface Bucket {
|
|
426
|
+
windowStart: number;
|
|
427
|
+
count: number;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Process-local in-memory store. No external dependencies, no persistence
|
|
432
|
+
* across restarts. Concurrency-safe within a single event loop (Map reads
|
|
433
|
+
* and writes are not preempted mid-operation in JS); no locking needed.
|
|
434
|
+
*
|
|
435
|
+
* Not safe across processes or hosts — use {@link createSqliteStore} for
|
|
436
|
+
* same-host multi-process, or a distributed store (future Redis backend)
|
|
437
|
+
* for multi-host.
|
|
438
|
+
*/
|
|
439
|
+
export function createInMemoryStore(): RateLimitStore {
|
|
440
|
+
const buckets = new Map<string, Bucket>();
|
|
441
|
+
let closed = false;
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
async hit(
|
|
445
|
+
key: string,
|
|
446
|
+
limit: number,
|
|
447
|
+
windowMs: number,
|
|
448
|
+
): Promise<RateLimitResult> {
|
|
449
|
+
if (closed) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
"[@mandujs/core/middleware/rate-limit] in-memory store is closed.",
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
const now = __now();
|
|
455
|
+
const existing = buckets.get(key);
|
|
456
|
+
|
|
457
|
+
let bucket: Bucket;
|
|
458
|
+
if (!existing || now - existing.windowStart >= windowMs) {
|
|
459
|
+
// Window rollover (or first hit). Start a fresh window anchored at
|
|
460
|
+
// `now` — the simplest model that still reports a precise resetAt.
|
|
461
|
+
bucket = { windowStart: now, count: 1 };
|
|
462
|
+
} else {
|
|
463
|
+
// Still inside the current window — increment.
|
|
464
|
+
bucket = { windowStart: existing.windowStart, count: existing.count + 1 };
|
|
465
|
+
}
|
|
466
|
+
buckets.set(key, bucket);
|
|
467
|
+
|
|
468
|
+
const resetAt = bucket.windowStart + windowMs;
|
|
469
|
+
const allowed = bucket.count <= limit;
|
|
470
|
+
// `remaining` reports post-hit budget; clamped at 0 so a blocked hit
|
|
471
|
+
// never reports negative remaining (would confuse clients rendering a
|
|
472
|
+
// progress indicator).
|
|
473
|
+
const remaining = Math.max(0, limit - bucket.count);
|
|
474
|
+
const retryAfterSeconds = allowed
|
|
475
|
+
? 0
|
|
476
|
+
: Math.max(1, Math.ceil((resetAt - now) / 1000));
|
|
477
|
+
|
|
478
|
+
return { allowed, remaining, resetAt, retryAfterSeconds };
|
|
479
|
+
},
|
|
480
|
+
|
|
481
|
+
async gcNow(olderThanMs: number): Promise<number> {
|
|
482
|
+
if (closed) return 0;
|
|
483
|
+
if (typeof olderThanMs !== "number" || olderThanMs < 0) {
|
|
484
|
+
throw new TypeError(
|
|
485
|
+
"[@mandujs/core/middleware/rate-limit] gcNow: olderThanMs must be a non-negative number.",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
const now = __now();
|
|
489
|
+
let deleted = 0;
|
|
490
|
+
// Iterating + deleting from a Map during traversal is safe per the
|
|
491
|
+
// ES spec — entries visited before deletion yield, already-visited
|
|
492
|
+
// entries are skipped. We still collect keys into a throwaway array
|
|
493
|
+
// to keep the hot-path clean across engines.
|
|
494
|
+
const stale: string[] = [];
|
|
495
|
+
for (const [key, bucket] of buckets) {
|
|
496
|
+
if (now - bucket.windowStart > olderThanMs) {
|
|
497
|
+
stale.push(key);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
for (const key of stale) {
|
|
501
|
+
buckets.delete(key);
|
|
502
|
+
deleted++;
|
|
503
|
+
}
|
|
504
|
+
return deleted;
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
async close(): Promise<void> {
|
|
508
|
+
if (closed) return;
|
|
509
|
+
closed = true;
|
|
510
|
+
buckets.clear();
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ─── SQLite store (re-export) ───────────────────────────────────────────────
|
|
516
|
+
|
|
517
|
+
// The SQLite store lives in its own module so callers who never need it
|
|
518
|
+
// don't pay the import cost of `@mandujs/core/db`.
|
|
519
|
+
export {
|
|
520
|
+
createSqliteStore,
|
|
521
|
+
type SqliteRateLimitStoreOptions,
|
|
522
|
+
} from "./sqlite-store";
|