@mandujs/core 0.54.32 → 0.55.0-beta.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 (414) hide show
  1. package/README.md +654 -654
  2. package/package.json +106 -210
  3. package/scripts/postinstall-lock.ts +153 -153
  4. package/src/a11y/__tests__/run-audit.test.ts +333 -333
  5. package/src/a11y/fix-hints.ts +76 -76
  6. package/src/a11y/index.ts +18 -18
  7. package/src/a11y/run-audit.ts +15 -15
  8. package/src/a11y/types.ts +125 -125
  9. package/src/agent/__tests__/apply.test.ts +224 -0
  10. package/src/agent/__tests__/context.test.ts +97 -98
  11. package/src/agent/apply.ts +1031 -0
  12. package/src/agent/context.ts +552 -552
  13. package/src/agent/index.ts +4 -3
  14. package/src/agent/plan.ts +161 -243
  15. package/src/agent/repair.ts +239 -162
  16. package/src/agent/sync.ts +197 -198
  17. package/src/agent/types.ts +242 -94
  18. package/src/agent/verify.ts +55 -55
  19. package/src/auth/__tests__/login.test.ts +1 -1
  20. package/src/auth/__tests__/password.test.ts +15 -5
  21. package/src/auth/__tests__/reset.test.ts +2 -2
  22. package/src/auth/__tests__/tokens.test.ts +274 -274
  23. package/src/auth/__tests__/verification.test.ts +274 -274
  24. package/src/auth/index.ts +76 -76
  25. package/src/auth/login.ts +225 -225
  26. package/src/auth/password.ts +14 -2
  27. package/src/auth/reset.ts +243 -243
  28. package/src/auth/tokens.ts +612 -612
  29. package/src/auth/verification.ts +253 -253
  30. package/src/brain/__tests__/redactor.test.ts +94 -94
  31. package/src/brain/adapters/__tests__/_helpers.ts +83 -83
  32. package/src/brain/adapters/__tests__/anthropic-oauth.test.ts +196 -196
  33. package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -193
  34. package/src/brain/adapters/__tests__/openai-oauth.test.ts +209 -209
  35. package/src/brain/adapters/__tests__/resolver.test.ts +143 -143
  36. package/src/brain/adapters/anthropic-oauth.ts +1 -1
  37. package/src/brain/adapters/chatgpt-auth.ts +300 -300
  38. package/src/brain/adapters/index.ts +319 -319
  39. package/src/brain/adapters/oauth-flow.ts +439 -439
  40. package/src/brain/consent.ts +240 -240
  41. package/src/brain/credentials.ts +396 -396
  42. package/src/brain/doctor/analyzer.ts +7 -7
  43. package/src/bundler/__snapshots__/build.test.ts.snap +5 -5
  44. package/src/bundler/__tests__/build-runner.ts +166 -166
  45. package/src/bundler/__tests__/client-boundary-transform.test.ts +524 -524
  46. package/src/bundler/__tests__/cold-start.test.ts +60 -60
  47. package/src/bundler/__tests__/css.test.ts +49 -20
  48. package/src/bundler/__tests__/dev-reliability.test.ts +619 -619
  49. package/src/bundler/__tests__/extended-watch.test.ts +711 -711
  50. package/src/bundler/__tests__/fast-refresh.test.ts +24 -24
  51. package/src/bundler/__tests__/generation.test.ts +447 -0
  52. package/src/bundler/__tests__/hdr.test.ts +24 -18
  53. package/src/bundler/__tests__/hmr-client.test.ts +62 -24
  54. package/src/bundler/__tests__/jsx-runtime-shim.test.ts +179 -140
  55. package/src/bundler/__tests__/manifest-schema.test.ts +305 -266
  56. package/src/bundler/__tests__/prod-smoke.test.ts +138 -138
  57. package/src/bundler/__tests__/reverse-import-graph.test.ts +42 -42
  58. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -573
  59. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -286
  60. package/src/bundler/__tests__/vendor-cache.test.ts +455 -455
  61. package/src/bundler/analyzer.ts +15 -15
  62. package/src/bundler/budget.ts +404 -404
  63. package/src/bundler/build.test.ts +898 -915
  64. package/src/bundler/build.ts +113 -24
  65. package/src/bundler/client-boundary-transform.ts +977 -977
  66. package/src/bundler/css.ts +65 -44
  67. package/src/bundler/dev.ts +105 -73
  68. package/src/bundler/fast-refresh-preamble.ts +47 -47
  69. package/src/bundler/generation.ts +602 -0
  70. package/src/bundler/index.ts +3 -3
  71. package/src/bundler/manifest-schema.ts +55 -40
  72. package/src/bundler/plugins/__tests__/block-generated-imports.test.ts +13 -13
  73. package/src/bundler/plugins/__tests__/react-compiler-config.test.ts +83 -83
  74. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -110
  75. package/src/bundler/plugins/block-generated-imports.ts +16 -15
  76. package/src/bundler/plugins/index.ts +83 -83
  77. package/src/bundler/plugins/react-compiler-config.ts +108 -108
  78. package/src/bundler/plugins/react-compiler-lint.ts +253 -253
  79. package/src/bundler/plugins/react-compiler.ts +162 -162
  80. package/src/bundler/prerender.ts +29 -9
  81. package/src/bundler/reverse-import-graph.ts +339 -339
  82. package/src/bundler/safe-build.test.ts +1 -1
  83. package/src/bundler/safe-build.ts +103 -103
  84. package/src/bundler/scenario-matrix.ts +229 -229
  85. package/src/bundler/types.ts +58 -56
  86. package/src/bundler/vendor-cache-types.ts +130 -130
  87. package/src/bundler/vendor-cache.ts +526 -526
  88. package/src/change/snapshot.ts +18 -18
  89. package/src/client/Form.tsx +105 -105
  90. package/src/client/Link.tsx +9 -9
  91. package/src/client/__tests__/props-serialization.test.ts +37 -37
  92. package/src/client/__tests__/use-sse.test.ts +153 -153
  93. package/src/client/globals.ts +1 -1
  94. package/src/client/hooks.ts +362 -362
  95. package/src/client/hydrate.ts +2 -2
  96. package/src/client/index.ts +1 -1
  97. package/src/client/island.ts +79 -79
  98. package/src/client/prefetch-helper.ts +55 -55
  99. package/src/client/props-serialization.ts +233 -233
  100. package/src/client/runtime-entry.ts +598 -598
  101. package/src/client/runtime.ts +1 -1
  102. package/src/client/serialize.ts +50 -50
  103. package/src/client/use-fetch.ts +6 -6
  104. package/src/client/use-head.ts +197 -197
  105. package/src/client/use-sse.ts +378 -378
  106. package/src/client/window-state.ts +101 -101
  107. package/src/components/Image-compat.ts +3 -0
  108. package/src/components/Image.tsx +162 -162
  109. package/src/config/validate.ts +1 -1
  110. package/src/config/watcher.ts +311 -311
  111. package/src/constants.ts +40 -40
  112. package/src/content/collection.ts +9 -9
  113. package/src/content/content-layer.ts +7 -7
  114. package/src/content/data-store.ts +245 -245
  115. package/src/content/frontmatter.ts +189 -189
  116. package/src/content/generate-types.ts +1 -1
  117. package/src/content/index.ts +2 -2
  118. package/src/content/loader-context.ts +171 -171
  119. package/src/content/loaders/api.ts +216 -216
  120. package/src/content/loaders/file.ts +172 -172
  121. package/src/content/loaders/glob.ts +253 -253
  122. package/src/content/loaders/index.ts +34 -34
  123. package/src/content/loaders/types.ts +137 -137
  124. package/src/content/meta-store.ts +209 -209
  125. package/src/content/prebuild.test.ts +571 -571
  126. package/src/content/prebuild.ts +636 -636
  127. package/src/content/schema.ts +20 -20
  128. package/src/content/sidebar.ts +630 -630
  129. package/src/content/slug.ts +110 -110
  130. package/src/content/types.ts +282 -282
  131. package/src/content/watcher.ts +135 -135
  132. package/src/contract/client-safe.test.ts +42 -42
  133. package/src/contract/client-safe.ts +114 -114
  134. package/src/contract/define.ts +11 -11
  135. package/src/contract/index.ts +1 -1
  136. package/src/contract/normalize.test.ts +276 -276
  137. package/src/contract/normalize.ts +410 -410
  138. package/src/contract/registry.test.ts +206 -206
  139. package/src/contract/route-helpers.ts +1 -1
  140. package/src/contract/rpc.ts +443 -443
  141. package/src/contract/types.ts +58 -58
  142. package/src/db/__tests__/db.test.ts +9 -9
  143. package/src/db/index.ts +2 -2
  144. package/src/db/migrations/history-table.ts +345 -345
  145. package/src/db/migrations/index.ts +3 -3
  146. package/src/db/migrations/lock.ts +324 -324
  147. package/src/db/migrations/runner.ts +650 -650
  148. package/src/deploy/cache.ts +140 -140
  149. package/src/deploy/compile/vercel.ts +344 -344
  150. package/src/deploy/index.ts +87 -87
  151. package/src/deploy/inference/brain.ts +268 -268
  152. package/src/deploy/inference/context.ts +82 -82
  153. package/src/deploy/inference/filling-extract.ts +245 -245
  154. package/src/deploy/inference/heuristic.ts +182 -182
  155. package/src/deploy/intent.ts +173 -173
  156. package/src/deploy/plan.ts +178 -178
  157. package/src/design/__tests__/agents-link.test.ts +109 -109
  158. package/src/design/__tests__/extract-patch-diff.test.ts +265 -265
  159. package/src/design/__tests__/lint.test.ts +110 -110
  160. package/src/design/__tests__/parser.test.ts +195 -195
  161. package/src/design/__tests__/tailwind-theme.test.ts +229 -229
  162. package/src/design/agents-link.ts +165 -165
  163. package/src/design/diff.ts +138 -138
  164. package/src/design/extract.ts +285 -285
  165. package/src/design/index.ts +102 -102
  166. package/src/design/lint.ts +209 -209
  167. package/src/design/parser.ts +555 -555
  168. package/src/design/patch.ts +241 -241
  169. package/src/design/scaffold.ts +147 -147
  170. package/src/design/tailwind-theme.ts +441 -441
  171. package/src/design/types.ts +210 -210
  172. package/src/desktop/__tests__/smoke.test.ts +2 -2
  173. package/src/desktop/__tests__/webview-fallback.test.ts +12 -32
  174. package/src/desktop/__tests__/window.test.ts +20 -99
  175. package/src/desktop/__tests__/worker.test.ts +266 -266
  176. package/src/desktop/index.ts +43 -43
  177. package/src/desktop/types.ts +158 -158
  178. package/src/desktop/webview-fallback.ts +17 -4
  179. package/src/desktop/window.ts +17 -8
  180. package/src/desktop/worker.ts +180 -180
  181. package/src/dev-error-overlay/__tests__/overlay-injector.test.ts +241 -241
  182. package/src/dev-error-overlay/index.ts +30 -30
  183. package/src/dev-error-overlay/overlay-injector.ts +243 -243
  184. package/src/dev-error-overlay/overlay-styles.ts +52 -52
  185. package/src/dev-error-overlay/types.ts +66 -66
  186. package/src/devtools/ai/context-builder.ts +375 -375
  187. package/src/devtools/ai/index.ts +25 -25
  188. package/src/devtools/ai/mcp-connector.ts +25 -25
  189. package/src/devtools/client/catchers/error-catcher.ts +344 -344
  190. package/src/devtools/client/catchers/index.ts +18 -18
  191. package/src/devtools/client/components/index.ts +39 -39
  192. package/src/devtools/client/components/mandu-character.tsx +331 -331
  193. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  194. package/src/devtools/client/components/panel/guard-panel.tsx +30 -30
  195. package/src/devtools/client/components/panel/islands-panel.tsx +16 -16
  196. package/src/devtools/client/components/panel/network-panel.tsx +291 -291
  197. package/src/devtools/client/components/panel/panel-container.tsx +1 -1
  198. package/src/devtools/client/components/panel/preview-panel.tsx +46 -46
  199. package/src/devtools/client/filters/context-filters.ts +282 -282
  200. package/src/devtools/client/filters/index.ts +16 -16
  201. package/src/devtools/client/index.ts +63 -63
  202. package/src/devtools/hook/create-hook.ts +207 -207
  203. package/src/devtools/hook/index.ts +13 -13
  204. package/src/devtools/index.ts +439 -439
  205. package/src/devtools/init.ts +265 -265
  206. package/src/devtools/protocol.ts +237 -237
  207. package/src/devtools/server/index.ts +17 -17
  208. package/src/devtools/types.ts +35 -35
  209. package/src/devtools/worker/index.ts +25 -25
  210. package/src/devtools/worker/redaction-worker.ts +233 -233
  211. package/src/diagnose/__tests__/checks.test.ts +136 -136
  212. package/src/diagnose/checks.ts +185 -185
  213. package/src/diagnose/index.ts +17 -17
  214. package/src/diagnose/run.ts +10 -10
  215. package/src/diagnose/types.ts +53 -53
  216. package/src/email/__tests__/email.test.ts +355 -355
  217. package/src/email/index.ts +282 -282
  218. package/src/email/smtp.ts +64 -64
  219. package/src/error/domains.ts +265 -265
  220. package/src/error/types.ts +6 -6
  221. package/src/errors/extractor.ts +409 -409
  222. package/src/errors/index.ts +19 -19
  223. package/src/filling/__tests__/session-sqlite.test.ts +5 -1
  224. package/src/filling/auth.ts +308 -308
  225. package/src/filling/body-parse.test.ts +60 -60
  226. package/src/filling/cookie-codec.ts +5 -3
  227. package/src/filling/deps.ts +265 -265
  228. package/src/filling/head-method.test.ts +154 -154
  229. package/src/filling/session-sqlite.ts +617 -617
  230. package/src/filling/sse.ts +5 -5
  231. package/src/filling/ws.ts +78 -78
  232. package/src/generator/generate.ts +30 -30
  233. package/src/generator/index.ts +3 -3
  234. package/src/generator/templates.test.ts +48 -48
  235. package/src/generator/templates.ts +219 -219
  236. package/src/guard/__tests__/design-inline-class.test.ts +219 -219
  237. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -347
  238. package/src/guard/analyzer.ts +360 -360
  239. package/src/guard/auto-correct.ts +1 -1
  240. package/src/guard/check.ts +39 -24
  241. package/src/guard/config-guard.ts +13 -13
  242. package/src/guard/contract-guard.ts +9 -9
  243. package/src/guard/define-rule.ts +243 -243
  244. package/src/guard/design-inline-class.ts +393 -393
  245. package/src/guard/file-type.test.ts +24 -24
  246. package/src/guard/fs-routes-policy.ts +51 -51
  247. package/src/guard/graph.ts +1 -1
  248. package/src/guard/healing.ts +36 -36
  249. package/src/guard/index.ts +11 -11
  250. package/src/guard/presets/atomic.ts +70 -70
  251. package/src/guard/presets/clean.ts +77 -77
  252. package/src/guard/presets/fsd.ts +79 -79
  253. package/src/guard/presets/hexagonal.ts +68 -68
  254. package/src/guard/reporter.ts +442 -442
  255. package/src/guard/rule-presets.ts +379 -379
  256. package/src/guard/semantic-slots.ts +1 -1
  257. package/src/guard/suggestions.ts +358 -358
  258. package/src/guard/tsgolint-bridge.ts +512 -512
  259. package/src/guard/types.ts +348 -348
  260. package/src/guard/watcher.ts +405 -405
  261. package/src/i18n/define.ts +126 -126
  262. package/src/i18n/index.ts +52 -52
  263. package/src/i18n/message-registry.ts +173 -173
  264. package/src/i18n/types.ts +112 -112
  265. package/src/id/__tests__/id.test.ts +3 -3
  266. package/src/id/index.ts +105 -105
  267. package/src/index.ts +9 -48
  268. package/src/internal/client-boundary.ts +266 -266
  269. package/src/internal/index.ts +2 -2
  270. package/src/kitchen/api/agent-devtools-api.ts +779 -779
  271. package/src/kitchen/api/bundle-inspector.ts +179 -0
  272. package/src/kitchen/api/errors-grouping.ts +126 -126
  273. package/src/kitchen/api/file-api.ts +11 -11
  274. package/src/kitchen/kitchen-handler.ts +36 -0
  275. package/src/kitchen/kitchen-ui.ts +456 -0
  276. package/src/logging/index.ts +22 -22
  277. package/src/logging/transports.ts +365 -365
  278. package/src/middleware/bridge.ts +147 -147
  279. package/src/middleware/compose.ts +134 -134
  280. package/src/middleware/compress.ts +62 -62
  281. package/src/middleware/cors.ts +47 -47
  282. package/src/middleware/csrf.ts +328 -328
  283. package/src/middleware/define.ts +132 -132
  284. package/src/middleware/jwt.ts +134 -134
  285. package/src/middleware/logger.ts +58 -58
  286. package/src/middleware/oauth/__tests__/oauth.test.ts +1 -1
  287. package/src/middleware/oauth/index.ts +505 -505
  288. package/src/middleware/oauth/providers.ts +115 -115
  289. package/src/middleware/rate-limit/index.ts +522 -522
  290. package/src/middleware/rate-limit/sqlite-store.ts +382 -382
  291. package/src/middleware/scheduler-cron.ts +96 -96
  292. package/src/middleware/secure/__tests__/secure.test.ts +360 -360
  293. package/src/middleware/secure/csp.ts +193 -193
  294. package/src/middleware/session.ts +174 -174
  295. package/src/middleware/timeout.ts +55 -55
  296. package/src/observability/logger-adapter.ts +36 -36
  297. package/src/observability/sqlite-store.ts +254 -254
  298. package/src/openapi/generator.ts +1 -1
  299. package/src/openapi/openapi.test.ts +43 -43
  300. package/src/perf/__tests__/user-marks.test.ts +354 -354
  301. package/src/perf/index.ts +133 -133
  302. package/src/perf/user-marks.ts +1 -1
  303. package/src/plugins/__tests__/lifecycle-integration.test.ts +272 -272
  304. package/src/plugins/__tests__/runner.test.ts +409 -409
  305. package/src/plugins/define.ts +124 -124
  306. package/src/plugins/examples/dep-check-plugin.ts +80 -80
  307. package/src/plugins/examples/prerender-cache-plugin.ts +111 -111
  308. package/src/plugins/examples/sitemap-plugin.ts +65 -65
  309. package/src/plugins/runner.ts +361 -361
  310. package/src/plugins/types.ts +368 -368
  311. package/src/report/index.ts +1 -1
  312. package/src/resource/__tests__/generator.test.ts +7 -7
  313. package/src/resource/__tests__/schema.test.ts +14 -14
  314. package/src/resource/ddl/__tests__/diff.test.ts +639 -639
  315. package/src/resource/ddl/__tests__/emit.test.ts +165 -165
  316. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -499
  317. package/src/resource/ddl/emit.ts +146 -146
  318. package/src/resource/ddl/persistence-types.ts +218 -218
  319. package/src/resource/ddl/type-map.ts +223 -223
  320. package/src/resource/ddl/types.ts +232 -232
  321. package/src/resource/generator-repo.ts +630 -630
  322. package/src/resource/generator-schema.ts +11 -11
  323. package/src/resource/generators/slot.ts +72 -72
  324. package/src/resource/schema.ts +21 -21
  325. package/src/router/client-entry.test.ts +227 -227
  326. package/src/router/client-entry.ts +218 -218
  327. package/src/router/fs-patterns.test.ts +96 -96
  328. package/src/router/fs-routes.test.ts +532 -532
  329. package/src/router/fs-routes.ts +53 -53
  330. package/src/router/fs-scanner.ts +216 -216
  331. package/src/router/fs-types.ts +19 -19
  332. package/src/router/route-source-analyzer.ts +521 -521
  333. package/src/routes/index.ts +74 -74
  334. package/src/routes/metadata-routes.ts +427 -427
  335. package/src/routes/types.ts +341 -341
  336. package/src/runtime/__tests__/devtools-adapter.test.ts +68 -68
  337. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -141
  338. package/src/runtime/__tests__/hdr-client.test.ts +223 -223
  339. package/src/runtime/__tests__/http-errors.test.ts +117 -117
  340. package/src/runtime/__tests__/inline-client-hydration.test.ts +237 -237
  341. package/src/runtime/__tests__/not-found.test.ts +152 -152
  342. package/src/runtime/__tests__/observability-lifecycle.test.ts +103 -103
  343. package/src/runtime/__tests__/page-render-response.test.ts +517 -517
  344. package/src/runtime/__tests__/request-middleware.test.ts +70 -70
  345. package/src/runtime/__tests__/searchparams-page-props.test.ts +81 -81
  346. package/src/runtime/adapter.ts +47 -47
  347. package/src/runtime/boundary.tsx +252 -252
  348. package/src/runtime/cache.ts +494 -494
  349. package/src/runtime/compose.ts +222 -222
  350. package/src/runtime/devtools-adapter.ts +68 -68
  351. package/src/runtime/escape.ts +34 -34
  352. package/src/runtime/fast-refresh-runtime.ts +322 -322
  353. package/src/runtime/fast-refresh-types.ts +1 -1
  354. package/src/runtime/handler.ts +65 -65
  355. package/src/runtime/handlers.ts +30 -11
  356. package/src/runtime/http-errors.ts +113 -113
  357. package/src/runtime/image-handler.ts +1 -1
  358. package/src/runtime/lifecycle.ts +381 -381
  359. package/src/runtime/logger.test.ts +345 -345
  360. package/src/runtime/middleware.ts +264 -264
  361. package/src/runtime/not-found.ts +93 -93
  362. package/src/runtime/observability-lifecycle.ts +290 -290
  363. package/src/runtime/openapi-endpoint.ts +236 -236
  364. package/src/runtime/page-render-response.ts +287 -274
  365. package/src/runtime/ppr.ts +74 -74
  366. package/src/runtime/rate-limit.ts +1 -1
  367. package/src/runtime/redirect.ts +1 -1
  368. package/src/runtime/registry.ts +171 -171
  369. package/src/runtime/request-middleware.ts +31 -31
  370. package/src/runtime/router.test.ts +4 -4
  371. package/src/runtime/router.ts +10 -10
  372. package/src/runtime/server.ts +127 -211
  373. package/src/runtime/shims.ts +48 -48
  374. package/src/runtime/ssr.ts +43 -17
  375. package/src/runtime/static-files.ts +369 -289
  376. package/src/runtime/streaming-ssr.ts +219 -180
  377. package/src/runtime/trace.ts +144 -144
  378. package/src/scheduler/__tests__/scheduler.test.ts +12 -11
  379. package/src/scheduler/index.ts +11 -5
  380. package/src/scheduler/validate.ts +169 -169
  381. package/src/seo/index.ts +219 -219
  382. package/src/seo/integration/ssr.ts +306 -306
  383. package/src/seo/render/basic.ts +435 -435
  384. package/src/seo/render/index.ts +143 -143
  385. package/src/seo/render/jsonld.ts +539 -539
  386. package/src/seo/render/opengraph.ts +197 -197
  387. package/src/seo/render/robots.ts +116 -116
  388. package/src/seo/render/sitemap.ts +137 -137
  389. package/src/seo/render/twitter.ts +127 -127
  390. package/src/seo/resolve/opengraph.ts +143 -143
  391. package/src/seo/resolve/robots.ts +73 -73
  392. package/src/seo/resolve/title.ts +94 -94
  393. package/src/seo/resolve/twitter.ts +73 -73
  394. package/src/seo/resolve/url.ts +104 -104
  395. package/src/seo/routes/index.ts +290 -290
  396. package/src/seo/types.ts +588 -588
  397. package/src/slot/validator.ts +39 -39
  398. package/src/spec/schema.ts +35 -35
  399. package/src/storage/s3/__tests__/s3.test.ts +479 -479
  400. package/src/storage/s3/index.ts +412 -412
  401. package/src/testing/__tests__/assertions.test.ts +632 -632
  402. package/src/testing/__tests__/reporter.test.ts +454 -454
  403. package/src/testing/assertions.ts +986 -986
  404. package/src/testing/db.ts +157 -157
  405. package/src/testing/index.ts +9 -3
  406. package/src/testing/lcov.ts +192 -0
  407. package/src/testing/mocks.ts +203 -203
  408. package/src/testing/session.ts +190 -190
  409. package/src/types/branded.ts +56 -56
  410. package/src/types/index.ts +1 -1
  411. package/src/utils/safe-io.ts +188 -188
  412. package/src/utils/string-safe.ts +298 -298
  413. package/src/watcher/__tests__/watcher.test.ts +59 -59
  414. package/src/watcher/watcher.ts +61 -61
@@ -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/compat/middleware/rate-limit/index";
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";