@mandujs/core 0.21.0 → 0.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/package.json +101 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +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";