@decohere/http 0.0.1-dev.125 → 0.0.1-dev.5

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 (292) hide show
  1. package/package.json +4 -13
  2. package/src/app/app.ts +147 -0
  3. package/src/app/module.ts +19 -0
  4. package/src/auth/ability.ts +56 -0
  5. package/src/auth/auth.config.ts +34 -0
  6. package/src/auth/auth.extensions.ts +115 -0
  7. package/src/auth/auth.types.ts +124 -0
  8. package/src/auth/casl-authorization.service.ts +62 -0
  9. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  10. package/src/auth/safe-outbound-url.ts +117 -0
  11. package/src/auth/token-service.ts +192 -0
  12. package/src/builder/builder.ts +118 -0
  13. package/{es6/src/const.js → src/const.ts} +25 -17
  14. package/src/health/health.controller.ts +34 -0
  15. package/src/health/health.extensions.ts +53 -0
  16. package/src/health/readiness.service.ts +33 -0
  17. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  18. package/src/hosting/hosted-service-host.ts +102 -0
  19. package/src/hosting/hosted-service.ts +31 -0
  20. package/src/http/http.compression.ts +38 -0
  21. package/src/http/http.extensions.ts +244 -0
  22. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  23. package/src/http/http.options.ts +41 -0
  24. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  25. package/src/http/http.static.ts +359 -0
  26. package/{es6/src/index.js → src/index.ts} +1 -0
  27. package/src/openapi/index.ts +22 -0
  28. package/src/openapi/openapi.generator.ts +78 -0
  29. package/src/openapi/openapi.types.ts +60 -0
  30. package/src/openapi/request-schema.ts +155 -0
  31. package/src/openapi/request-validator.ts +95 -0
  32. package/src/providers/fastify.provider.ts +454 -0
  33. package/src/routing/anvil.types.ts +150 -0
  34. package/src/routing/decorators/argument.decorator.ts +33 -0
  35. package/src/routing/decorators/auth.decorator.ts +79 -0
  36. package/src/routing/decorators/controller.decorator.ts +9 -0
  37. package/src/routing/decorators/field.decorator.ts +15 -0
  38. package/src/routing/decorators/request.decorator.ts +34 -0
  39. package/src/routing/decorators/response.decorator.ts +18 -0
  40. package/src/routing/decorators/route.decorator.ts +52 -0
  41. package/src/routing/filters/exception-filter-host.ts +90 -0
  42. package/src/routing/filters/exception-filter.ts +29 -0
  43. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  44. package/src/routing/middleware/middleware.ts +26 -0
  45. package/src/routing/middleware/request-id.ts +18 -0
  46. package/src/routing/path.util.ts +38 -0
  47. package/src/routing/pipes/pipes.ts +132 -0
  48. package/src/routing/services/route-argument-resolver.ts +92 -0
  49. package/src/routing/services/route-registry.ts +229 -0
  50. package/src/routing/services/router.ts +166 -0
  51. package/src/sse/sse.decorator.ts +41 -0
  52. package/src/sse/sse.encoder.ts +86 -0
  53. package/src/sse/sse.types.ts +47 -0
  54. package/src/sse/sse.wire.ts +34 -0
  55. package/src/testing/boot-module.ts +63 -0
  56. package/src/testing/sse-reader.ts +120 -0
  57. package/tests/app.hosted-services.spec.ts +292 -0
  58. package/tests/app.shutdown.integration.spec.ts +160 -0
  59. package/tests/app.spec.ts +132 -0
  60. package/tests/auth.ability.spec.ts +48 -0
  61. package/tests/auth.decorators.spec.ts +20 -0
  62. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  63. package/tests/auth.integration.spec.ts +234 -0
  64. package/tests/auth.jwks-cache.spec.ts +52 -0
  65. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  66. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  67. package/tests/auth.oidc.integration.spec.ts +161 -0
  68. package/tests/auth.token-service.spec.ts +251 -0
  69. package/tests/builder.spec.ts +12 -0
  70. package/tests/health.integration.spec.ts +111 -0
  71. package/tests/helpers/listen-ephemeral.ts +13 -0
  72. package/tests/helpers/local-oidc-server.ts +100 -0
  73. package/tests/http.access-log.integration.spec.ts +111 -0
  74. package/tests/http.boolean-query.integration.spec.ts +59 -0
  75. package/tests/http.compression.integration.spec.ts +604 -0
  76. package/tests/http.date-query.integration.spec.ts +71 -0
  77. package/tests/http.global-prefix.integration.spec.ts +284 -0
  78. package/tests/http.number-query.integration.spec.ts +65 -0
  79. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  80. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  81. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  82. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  83. package/tests/http.security.integration.spec.ts +106 -0
  84. package/tests/http.static.integration.spec.ts +847 -0
  85. package/tests/http.static.spec.ts +111 -0
  86. package/tests/http.surface.integration.spec.ts +167 -0
  87. package/tests/openapi.integration.spec.ts +193 -0
  88. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  89. package/tests/routing.decorators.spec.ts +15 -0
  90. package/tests/routing.registry.spec.ts +44 -0
  91. package/tests/routing.resolver.spec.ts +92 -0
  92. package/tests/sse.integration.spec.ts +421 -0
  93. package/tests/sse.wire.spec.ts +33 -0
  94. package/LICENSE +0 -15
  95. package/commonjs/src/app/app.js +0 -166
  96. package/commonjs/src/app/index.js +0 -19
  97. package/commonjs/src/app/module.js +0 -17
  98. package/commonjs/src/auth/ability.js +0 -54
  99. package/commonjs/src/auth/auth.config.js +0 -23
  100. package/commonjs/src/auth/auth.extensions.js +0 -106
  101. package/commonjs/src/auth/auth.types.js +0 -65
  102. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  103. package/commonjs/src/auth/index.js +0 -23
  104. package/commonjs/src/auth/principal-provider.js +0 -19
  105. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  106. package/commonjs/src/auth/token-service.js +0 -151
  107. package/commonjs/src/builder/builder.js +0 -95
  108. package/commonjs/src/builder/index.js +0 -18
  109. package/commonjs/src/const.js +0 -87
  110. package/commonjs/src/health/health.controller.js +0 -75
  111. package/commonjs/src/health/health.extensions.js +0 -50
  112. package/commonjs/src/health/index.js +0 -21
  113. package/commonjs/src/health/readiness.service.js +0 -67
  114. package/commonjs/src/health/readiness.types.js +0 -7
  115. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  116. package/commonjs/src/hosting/hosted-service.js +0 -11
  117. package/commonjs/src/hosting/index.js +0 -19
  118. package/commonjs/src/http/http.compression.js +0 -27
  119. package/commonjs/src/http/http.extensions.js +0 -269
  120. package/commonjs/src/http/http.options.js +0 -26
  121. package/commonjs/src/http/http.options.store.js +0 -20
  122. package/commonjs/src/http/http.security.js +0 -27
  123. package/commonjs/src/http/http.static.js +0 -248
  124. package/commonjs/src/http/index.js +0 -22
  125. package/commonjs/src/index.js +0 -27
  126. package/commonjs/src/openapi/index.js +0 -45
  127. package/commonjs/src/openapi/openapi.generator.js +0 -103
  128. package/commonjs/src/openapi/openapi.types.js +0 -4
  129. package/commonjs/src/openapi/request-schema.js +0 -187
  130. package/commonjs/src/openapi/request-validator.js +0 -85
  131. package/commonjs/src/providers/fastify.provider.js +0 -391
  132. package/commonjs/src/providers/index.js +0 -18
  133. package/commonjs/src/routing/anvil.types.js +0 -99
  134. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  135. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  136. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  137. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  138. package/commonjs/src/routing/decorators/index.js +0 -24
  139. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  140. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  141. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  142. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  143. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  144. package/commonjs/src/routing/index.js +0 -25
  145. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  146. package/commonjs/src/routing/middleware/middleware.js +0 -20
  147. package/commonjs/src/routing/middleware/request-id.js +0 -30
  148. package/commonjs/src/routing/path.util.js +0 -51
  149. package/commonjs/src/routing/pipes/index.js +0 -18
  150. package/commonjs/src/routing/pipes/pipes.js +0 -145
  151. package/commonjs/src/routing/services/index.js +0 -18
  152. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  153. package/commonjs/src/routing/services/route-registry.js +0 -194
  154. package/commonjs/src/routing/services/router.js +0 -158
  155. package/commonjs/src/sse/index.js +0 -26
  156. package/commonjs/src/sse/sse.decorator.js +0 -42
  157. package/commonjs/src/sse/sse.encoder.js +0 -73
  158. package/commonjs/src/sse/sse.types.js +0 -36
  159. package/commonjs/src/sse/sse.wire.js +0 -40
  160. package/commonjs/src/testing/boot-module.js +0 -38
  161. package/commonjs/src/testing/index.js +0 -20
  162. package/commonjs/src/testing/sse-reader.js +0 -98
  163. package/es6/src/app/app.js +0 -148
  164. package/es6/src/app/index.js +0 -2
  165. package/es6/src/app/module.js +0 -7
  166. package/es6/src/auth/ability.js +0 -45
  167. package/es6/src/auth/auth.config.js +0 -19
  168. package/es6/src/auth/auth.extensions.js +0 -83
  169. package/es6/src/auth/auth.types.js +0 -35
  170. package/es6/src/auth/casl-authorization.service.js +0 -40
  171. package/es6/src/auth/index.js +0 -6
  172. package/es6/src/auth/principal-provider.js +0 -14
  173. package/es6/src/auth/safe-outbound-url.js +0 -99
  174. package/es6/src/auth/token-service.js +0 -141
  175. package/es6/src/builder/builder.js +0 -85
  176. package/es6/src/builder/index.js +0 -1
  177. package/es6/src/health/health.controller.js +0 -65
  178. package/es6/src/health/health.extensions.js +0 -40
  179. package/es6/src/health/index.js +0 -4
  180. package/es6/src/health/readiness.service.js +0 -57
  181. package/es6/src/health/readiness.types.js +0 -4
  182. package/es6/src/hosting/hosted-service-host.js +0 -81
  183. package/es6/src/hosting/hosted-service.js +0 -8
  184. package/es6/src/hosting/index.js +0 -2
  185. package/es6/src/http/http.compression.js +0 -9
  186. package/es6/src/http/http.extensions.js +0 -259
  187. package/es6/src/http/http.options.js +0 -16
  188. package/es6/src/http/http.options.store.js +0 -13
  189. package/es6/src/http/http.security.js +0 -9
  190. package/es6/src/http/http.static.js +0 -224
  191. package/es6/src/http/index.js +0 -5
  192. package/es6/src/openapi/index.js +0 -3
  193. package/es6/src/openapi/openapi.generator.js +0 -82
  194. package/es6/src/openapi/openapi.types.js +0 -1
  195. package/es6/src/openapi/request-schema.js +0 -160
  196. package/es6/src/openapi/request-validator.js +0 -71
  197. package/es6/src/providers/fastify.provider.js +0 -376
  198. package/es6/src/providers/index.js +0 -1
  199. package/es6/src/routing/anvil.types.js +0 -57
  200. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  201. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  202. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  203. package/es6/src/routing/decorators/field.decorator.js +0 -15
  204. package/es6/src/routing/decorators/index.js +0 -7
  205. package/es6/src/routing/decorators/request.decorator.js +0 -24
  206. package/es6/src/routing/decorators/response.decorator.js +0 -16
  207. package/es6/src/routing/decorators/route.decorator.js +0 -37
  208. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  209. package/es6/src/routing/filters/exception-filter.js +0 -10
  210. package/es6/src/routing/index.js +0 -8
  211. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  212. package/es6/src/routing/middleware/middleware.js +0 -10
  213. package/es6/src/routing/middleware/request-id.js +0 -14
  214. package/es6/src/routing/path.util.js +0 -33
  215. package/es6/src/routing/pipes/index.js +0 -1
  216. package/es6/src/routing/pipes/pipes.js +0 -118
  217. package/es6/src/routing/services/index.js +0 -1
  218. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  219. package/es6/src/routing/services/route-registry.js +0 -184
  220. package/es6/src/routing/services/router.js +0 -148
  221. package/es6/src/sse/index.js +0 -2
  222. package/es6/src/sse/sse.decorator.js +0 -31
  223. package/es6/src/sse/sse.encoder.js +0 -67
  224. package/es6/src/sse/sse.types.js +0 -22
  225. package/es6/src/sse/sse.wire.js +0 -30
  226. package/es6/src/testing/boot-module.js +0 -35
  227. package/es6/src/testing/index.js +0 -2
  228. package/es6/src/testing/sse-reader.js +0 -91
  229. package/types/src/app/app.d.ts +0 -41
  230. package/types/src/app/module.d.ts +0 -15
  231. package/types/src/auth/ability.d.ts +0 -19
  232. package/types/src/auth/auth.config.d.ts +0 -28
  233. package/types/src/auth/auth.extensions.d.ts +0 -53
  234. package/types/src/auth/auth.types.d.ts +0 -73
  235. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  236. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  237. package/types/src/auth/token-service.d.ts +0 -31
  238. package/types/src/builder/builder.d.ts +0 -36
  239. package/types/src/const.d.ts +0 -32
  240. package/types/src/health/health.controller.d.ts +0 -16
  241. package/types/src/health/health.extensions.d.ts +0 -31
  242. package/types/src/health/readiness.service.d.ts +0 -9
  243. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  244. package/types/src/hosting/hosted-service.d.ts +0 -31
  245. package/types/src/http/http.compression.d.ts +0 -32
  246. package/types/src/http/http.extensions.d.ts +0 -79
  247. package/types/src/http/http.options.d.ts +0 -39
  248. package/types/src/http/http.static.d.ts +0 -160
  249. package/types/src/index.d.ts +0 -10
  250. package/types/src/openapi/index.d.ts +0 -6
  251. package/types/src/openapi/openapi.generator.d.ts +0 -6
  252. package/types/src/openapi/openapi.types.d.ts +0 -58
  253. package/types/src/openapi/request-schema.d.ts +0 -21
  254. package/types/src/openapi/request-validator.d.ts +0 -9
  255. package/types/src/providers/fastify.provider.d.ts +0 -56
  256. package/types/src/routing/anvil.types.d.ts +0 -119
  257. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  258. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  259. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  260. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  261. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  262. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  263. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  264. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  265. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  266. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  267. package/types/src/routing/middleware/middleware.d.ts +0 -10
  268. package/types/src/routing/middleware/request-id.d.ts +0 -6
  269. package/types/src/routing/path.util.d.ts +0 -9
  270. package/types/src/routing/pipes/pipes.d.ts +0 -21
  271. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  272. package/types/src/routing/services/route-registry.d.ts +0 -20
  273. package/types/src/routing/services/router.d.ts +0 -22
  274. package/types/src/sse/sse.decorator.d.ts +0 -17
  275. package/types/src/sse/sse.encoder.d.ts +0 -16
  276. package/types/src/sse/sse.types.d.ts +0 -37
  277. package/types/src/sse/sse.wire.d.ts +0 -9
  278. package/types/src/testing/boot-module.d.ts +0 -34
  279. package/types/src/testing/sse-reader.d.ts +0 -29
  280. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  281. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  282. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  283. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  284. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  285. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  286. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  287. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  288. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  289. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  290. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  291. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  292. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
@@ -0,0 +1,95 @@
1
+ import { Ajv, type ErrorObject, type ValidateFunction } from "ajv";
2
+ import type { HttpRequest, MappedRoute, ValidationIssue } from "../routing/anvil.types.js";
3
+ import { ValidationError } from "../routing/anvil.types.js";
4
+ import { buildRouteRequestSchemas, pathParamsToSchema } from "./request-schema.js";
5
+
6
+ export type { ValidationIssue };
7
+ export { ValidationError };
8
+
9
+ /**
10
+ * Fail-closed request validation from route / OpenAPI / `@Field` metadata.
11
+ * Uses AJV against the same schemas as the OpenAPI generator (no type coercion).
12
+ */
13
+ export function validateRequest(route: MappedRoute, request: HttpRequest): void {
14
+ const { params, body } = getValidators(route);
15
+ const issues: ValidationIssue[] = [];
16
+
17
+ if (params != null) {
18
+ collectIssues(params, request.params ?? {}, "/params", issues);
19
+ }
20
+ if (body != null) {
21
+ collectIssues(body, request.body, "/body", issues);
22
+ }
23
+
24
+ if (issues.length > 0) {
25
+ throw new ValidationError("Validation failed", issues);
26
+ }
27
+ }
28
+
29
+ const ajv = new Ajv({
30
+ allErrors: true,
31
+ coerceTypes: false,
32
+ removeAdditional: false,
33
+ useDefaults: false,
34
+ strict: false,
35
+ });
36
+
37
+ type RouteValidators = { params?: ValidateFunction; body?: ValidateFunction };
38
+
39
+ /**
40
+ * Compiled once per route and reused. Compiling per request was slow, and AJV keeps every
41
+ * schema it compiles, so memory grew with traffic (#498).
42
+ */
43
+ const validatorsByRoute = new WeakMap<MappedRoute, RouteValidators>();
44
+
45
+ function getValidators(route: MappedRoute): RouteValidators {
46
+ let validators = validatorsByRoute.get(route);
47
+ if (validators == null) {
48
+ const schemas = buildRouteRequestSchemas(route);
49
+ const paramsSchema = pathParamsToSchema(schemas.pathParams);
50
+ validators = {
51
+ params: paramsSchema != null ? ajv.compile(paramsSchema) : undefined,
52
+ body: schemas.body != null ? ajv.compile(schemas.body) : undefined,
53
+ };
54
+ validatorsByRoute.set(route, validators);
55
+ }
56
+ return validators;
57
+ }
58
+
59
+ function collectIssues(
60
+ validate: ValidateFunction,
61
+ data: unknown,
62
+ rootPath: string,
63
+ issues: ValidationIssue[],
64
+ ): void {
65
+ if (validate(data)) {
66
+ return;
67
+ }
68
+ for (const err of validate.errors ?? []) {
69
+ issues.push(toIssue(err, rootPath));
70
+ }
71
+ }
72
+
73
+ function toIssue(err: ErrorObject, rootPath: string): ValidationIssue {
74
+ const instance = err.instancePath ?? "";
75
+ const path = instance.length > 0 ? `${rootPath}${instance}` : rootPath;
76
+ const message = err.message ?? "is invalid";
77
+ return {
78
+ path,
79
+ message: formatAjvMessage(err, message),
80
+ keyword: err.keyword,
81
+ };
82
+ }
83
+
84
+ function formatAjvMessage(err: ErrorObject, fallback: string): string {
85
+ if (err.keyword === "required" && typeof err.params?.missingProperty === "string") {
86
+ return `must have required property '${err.params.missingProperty}'`;
87
+ }
88
+ if (
89
+ err.keyword === "additionalProperties" &&
90
+ typeof err.params?.additionalProperty === "string"
91
+ ) {
92
+ return `must NOT have additional property '${err.params.additionalProperty}'`;
93
+ }
94
+ return fallback;
95
+ }
@@ -0,0 +1,454 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import { inject } from "@decohere/di/decorators";
6
+ import { scribe } from "@decohere/scribe";
7
+ import compress from "@fastify/compress";
8
+ import cors from "@fastify/cors";
9
+ import helmet from "@fastify/helmet";
10
+ import rateLimit from "@fastify/rate-limit";
11
+ import fastifyStatic from "@fastify/static";
12
+ import Fastify, {
13
+ type FastifyInstance,
14
+ type FastifyReply,
15
+ type FastifyRequest,
16
+ } from "fastify";
17
+ import {
18
+ ExceptionFilterHostToken,
19
+ HttpOptionsToken,
20
+ RouteRegistryToken,
21
+ RouterToken,
22
+ } from "../const.js";
23
+ import type { HttpOptions } from "../http/http.options.js";
24
+ import {
25
+ DEFAULT_HTTP_BODY_LIMIT,
26
+ DEFAULT_HTTP_RATE_LIMIT,
27
+ type HelmetOptions,
28
+ type RateLimitOptions,
29
+ } from "../http/http.security.js";
30
+ import {
31
+ type CompressionOptions,
32
+ DEFAULT_COMPRESSION_ENCODINGS,
33
+ DEFAULT_COMPRESSION_THRESHOLD,
34
+ } from "../http/http.compression.js";
35
+ import { type ResolvedStaticMount, staticFiles } from "../http/http.static.js";
36
+ import { generateOpenApiDocument, resolveOpenApiPath } from "../openapi/openapi.generator.js";
37
+ import {
38
+ type CorsOptions,
39
+ type HttpProvider,
40
+ HttpRequest,
41
+ type MappedRoute,
42
+ type StartOptions,
43
+ type StartResponse,
44
+ type StopResponse,
45
+ } from "../routing/anvil.types.js";
46
+ import {
47
+ DefaultHttpExceptionFilter,
48
+ type ExceptionFilterHost,
49
+ } from "../routing/filters/exception-filter-host.js";
50
+ import { REQUEST_ID_HEADER, resolveRequestId } from "../routing/middleware/request-id.js";
51
+ import { normalizePrefix } from "../routing/path.util.js";
52
+ import type { RouteRegistry } from "../routing/services/route-registry.js";
53
+ import type { Router } from "../routing/services/router.js";
54
+ import { encodeSse } from "../sse/sse.encoder.js";
55
+ import { DEFAULT_SSE_HEARTBEAT_MS, SseResponse } from "../sse/sse.types.js";
56
+
57
+ export class FastifyProvider implements HttpProvider {
58
+ public readonly provider: FastifyInstance;
59
+ public readonly router: Router;
60
+ private readonly routeRegistry: RouteRegistry;
61
+ private readonly httpOptions?: HttpOptions;
62
+ private readonly exceptionFilterHost?: ExceptionFilterHost;
63
+ private closing: Promise<void> | undefined;
64
+ private readonly logger = scribe.logger(FastifyProvider.name);
65
+ /** One per live `@Sse()` request, so stopping the app can end the streams. */
66
+ private readonly openStreams = new Set<AbortController>();
67
+
68
+ public constructor(
69
+ @inject(RouteRegistryToken) routeRegistry?: RouteRegistry,
70
+ @inject(RouterToken) router?: Router,
71
+ @inject(HttpOptionsToken) httpOptions?: HttpOptions,
72
+ @inject(ExceptionFilterHostToken) exceptionFilterHost?: ExceptionFilterHost,
73
+ ) {
74
+ this.routeRegistry = routeRegistry;
75
+ this.router = router;
76
+ this.httpOptions = httpOptions;
77
+ this.exceptionFilterHost = exceptionFilterHost;
78
+ this.provider = Fastify({
79
+ logger: false,
80
+ requestIdHeader: REQUEST_ID_HEADER,
81
+ genReqId: () => randomUUID(),
82
+ bodyLimit: httpOptions?.bodyLimit ?? DEFAULT_HTTP_BODY_LIMIT,
83
+ });
84
+ }
85
+
86
+ public async start<T extends StartOptions>(options?: T): Promise<StartResponse> {
87
+ const statics = await this.resolveStatics();
88
+ await this.registerHelmet(this.httpOptions?.helmet);
89
+ await this.registerRateLimit(this.httpOptions?.rateLimit, statics);
90
+ await this.registerCors(this.httpOptions?.cors);
91
+ await this.registerCompression(this.httpOptions?.compression);
92
+ this.registerOpenApi(this.httpOptions);
93
+ this.registerAllRoutes(this.routeRegistry);
94
+ for (const mount of statics) {
95
+ await this.registerStatic(mount, statics);
96
+ }
97
+ this.closeConnectionsWhileStopping();
98
+ await this.provider.listen(options);
99
+ return {
100
+ addresses: this.addresses,
101
+ };
102
+ }
103
+
104
+ public async stop(): Promise<StopResponse> {
105
+ // App stops HTTP before disposing the container, which calls this again.
106
+ // A stream never finishes on its own, and close() waits for open responses, so end them first.
107
+ for (const stream of this.openStreams) {
108
+ stream.abort(new Error("The app is stopping"));
109
+ }
110
+ this.closing ??= this.provider.close();
111
+ await this.closing;
112
+ return { message: "closed" };
113
+ }
114
+
115
+ public async [Symbol.asyncDispose](): Promise<void> {
116
+ await this.stop();
117
+ }
118
+
119
+ /**
120
+ * Once stopping, tell clients to drop the connection after their response. Otherwise
121
+ * a request that was in flight leaves a kept-alive socket that holds `close()` open
122
+ * until Node's keep-alive timeout (5s).
123
+ */
124
+ private closeConnectionsWhileStopping() {
125
+ this.provider.addHook("onSend", async (_request, reply, payload) => {
126
+ if (this.closing) reply.header("connection", "close");
127
+ return payload;
128
+ });
129
+ }
130
+
131
+ public get addresses() {
132
+ return this.provider
133
+ .addresses()
134
+ .filter((a) => a.family === "IPv4")
135
+ .map((a) => `http://${a.address}:${a.port}`);
136
+ }
137
+
138
+ private async registerHelmet(helmetOptions?: HelmetOptions) {
139
+ if (helmetOptions === false) {
140
+ return;
141
+ }
142
+ await this.provider.register(helmet, helmetOptions ?? {});
143
+ }
144
+
145
+ private async registerRateLimit(
146
+ rateLimitOptions: RateLimitOptions | undefined,
147
+ statics: readonly ResolvedStaticMount[],
148
+ ) {
149
+ if (rateLimitOptions === false) {
150
+ return;
151
+ }
152
+ // Static files are outside the rate limit (ADR 0007). Matched on the route the request
153
+ // resolved to, not on its URL, so only requests the static plugin serves are exempt.
154
+ const staticRoutes = new Set(statics.map((mount) => `${mount.prefix}*`));
155
+ const userAllowList = rateLimitOptions?.allowList;
156
+ await this.provider.register(rateLimit, {
157
+ ...DEFAULT_HTTP_RATE_LIMIT,
158
+ ...rateLimitOptions,
159
+ allowList: (request: FastifyRequest, key: string) => {
160
+ if (staticRoutes.has(request.routeOptions.url ?? "")) return true;
161
+ if (typeof userAllowList === "function") return userAllowList(request, key);
162
+ return Array.isArray(userAllowList) && userAllowList.includes(key);
163
+ },
164
+ });
165
+ }
166
+
167
+ private async resolveStatics(): Promise<ResolvedStaticMount[]> {
168
+ return staticFiles.resolve(this.httpOptions?.statics ?? [], {
169
+ nodeEnv: process.env.NODE_ENV,
170
+ directoryExists: async (directory) =>
171
+ (await stat(directory).catch(() => undefined))?.isDirectory() === true,
172
+ warn: (message) => this.logger.warn(message),
173
+ });
174
+ }
175
+
176
+ /**
177
+ * One encapsulated context per mount: its not-found handler (the SPA fallback) applies to
178
+ * paths under the prefix and nowhere else, so an API 404 is never answered with the UI.
179
+ */
180
+ private async registerStatic(
181
+ mount: ResolvedStaticMount,
182
+ mounts: readonly ResolvedStaticMount[],
183
+ ) {
184
+ const withoutSlash = mount.prefix.slice(0, -1);
185
+ const isRoot = mount.prefix === "/";
186
+ const excluded = staticFiles.excluder(mount.exclude);
187
+ const belongsToApi = this.apiBoundary();
188
+ // Requests that already fell back once: a missing index must be a 404, not a loop.
189
+ const fellBack = new WeakSet<FastifyRequest>();
190
+ await this.provider.register(
191
+ async (scope) => {
192
+ await scope.register(fastifyStatic, {
193
+ root: mount.root,
194
+ prefix: "/",
195
+ index: mount.index,
196
+ dotfiles: "ignore",
197
+ list: false,
198
+ redirect: false,
199
+ etag: true,
200
+ lastModified: true,
201
+ // The header is set in setHeaders, per file.
202
+ cacheControl: false,
203
+ setHeaders: (reply, filePath) => {
204
+ reply.header(
205
+ "cache-control",
206
+ staticFiles.cacheControlFor(mount, path.relative(mount.root, filePath)),
207
+ );
208
+ },
209
+ });
210
+ // Excluded paths are not served at all: not as a file, not as the fallback.
211
+ scope.addHook("onRequest", async (request, reply) => {
212
+ if (excluded(staticFiles.normalizePathname(request.url))) {
213
+ return reply.callNotFound();
214
+ }
215
+ });
216
+ scope.setNotFoundHandler((request, reply) => {
217
+ const [pathname = "", query] = request.url.split("?");
218
+ const readOnly = request.method === "GET" || request.method === "HEAD";
219
+ // `/ui` goes to `/ui/`. A request for it reaches the root mount's handler instead
220
+ // when there is one, so that handler redirects for the other mounts too.
221
+ const redirectTo = mounts.find(
222
+ (other) =>
223
+ other.prefix !== "/" &&
224
+ other.prefix.slice(0, -1) === pathname &&
225
+ (isRoot || other === mount),
226
+ );
227
+ if (readOnly && redirectTo != null) {
228
+ return reply.redirect(
229
+ query == null ? redirectTo.prefix : `${redirectTo.prefix}?${query}`,
230
+ 301,
231
+ );
232
+ }
233
+ const normalized = staticFiles.normalizePathname(request.url);
234
+ if (
235
+ mount.spaFallback &&
236
+ !fellBack.has(request) &&
237
+ !excluded(normalized) &&
238
+ staticFiles.shouldFallBack(
239
+ { method: request.method, pathname: normalized, accept: request.headers.accept },
240
+ belongsToApi,
241
+ )
242
+ ) {
243
+ fellBack.add(request);
244
+ return reply.sendFile(mount.index);
245
+ }
246
+ return reply.code(404).send({
247
+ message: `Route ${request.method}:${pathname} not found`,
248
+ error: "Not Found",
249
+ statusCode: 404,
250
+ });
251
+ });
252
+ },
253
+ { prefix: withoutSlash },
254
+ );
255
+ }
256
+
257
+ /** What the static fallback leaves to the API: the global prefix, else the registered routes. ADR 0007. */
258
+ private apiBoundary(): (pathname: string) => boolean {
259
+ return staticFiles.apiBoundary({
260
+ globalPrefix: normalizePrefix(this.httpOptions?.globalPrefix),
261
+ openApiPath:
262
+ this.httpOptions?.openapi == null
263
+ ? undefined
264
+ : resolveOpenApiPath(this.httpOptions.openapi),
265
+ routePaths: [...this.routeRegistry.routes].map((route) => route.path),
266
+ });
267
+ }
268
+
269
+ /**
270
+ * `@fastify/compress` with best-practice defaults; the plugin owns the policy. Registered
271
+ * before any route so every response goes through it, errors and static files included.
272
+ * `providerOptions` is merged last, so a user can override any plugin option.
273
+ */
274
+ private async registerCompression(compressionOptions: CompressionOptions | undefined) {
275
+ if (compressionOptions === false) {
276
+ return;
277
+ }
278
+ await this.provider.register(compress, {
279
+ global: true,
280
+ threshold: compressionOptions?.threshold ?? DEFAULT_COMPRESSION_THRESHOLD,
281
+ encodings: [...(compressionOptions?.encodings ?? DEFAULT_COMPRESSION_ENCODINGS)],
282
+ ...(compressionOptions?.types != null && { customTypes: compressionOptions.types }),
283
+ ...compressionOptions?.providerOptions,
284
+ });
285
+ }
286
+
287
+ private async registerCors(corsOptions?: CorsOptions) {
288
+ if (corsOptions == null) {
289
+ return;
290
+ }
291
+ await this.provider.register(cors, {
292
+ origin: corsOptions.origin,
293
+ methods: corsOptions.methods,
294
+ allowedHeaders: corsOptions.allowedHeaders,
295
+ exposedHeaders: corsOptions.exposedHeaders,
296
+ credentials: corsOptions.credentials,
297
+ maxAge: corsOptions.maxAge,
298
+ });
299
+ }
300
+
301
+ private registerOpenApi(httpOptions?: HttpOptions) {
302
+ const openapi = httpOptions?.openapi;
303
+ if (openapi == null) {
304
+ return;
305
+ }
306
+ const path = resolveOpenApiPath(openapi);
307
+ const document = generateOpenApiDocument(this.routeRegistry, openapi);
308
+ this.provider.get(path, async (_request, reply) => {
309
+ reply.header("content-type", "application/json; charset=utf-8");
310
+ return document;
311
+ });
312
+ }
313
+
314
+ private registerAllRoutes(registry: RouteRegistry) {
315
+ for (const route of registry.routes) {
316
+ const { key, method, path } = route;
317
+ this.provider.route({
318
+ method,
319
+ url: path,
320
+ handler: (request, reply) => this.routeRequest(route, key, request, reply),
321
+ });
322
+ }
323
+ }
324
+
325
+ private async routeRequest(
326
+ route: MappedRoute,
327
+ key: string,
328
+ request: FastifyRequest,
329
+ reply: FastifyReply,
330
+ ) {
331
+ const httpRequest = this.fastifyToRequest(request);
332
+ const disconnect = this.abortOnDisconnect(reply, httpRequest, route.sse != null);
333
+ try {
334
+ const response = await this.router.route(key, httpRequest);
335
+ if (route.httpCode != null) {
336
+ reply.status(route.httpCode);
337
+ }
338
+ if (route.headers != null) {
339
+ for (const [name, value] of Object.entries(route.headers)) {
340
+ reply.header(name, value);
341
+ }
342
+ }
343
+ this.applyResponseHeaders(reply, httpRequest);
344
+ if (response instanceof SseResponse) {
345
+ return this.sendEventStream(route, reply, response, disconnect);
346
+ }
347
+ return response;
348
+ } catch (error) {
349
+ const result = this.exceptionFilterHost
350
+ ? await this.exceptionFilterHost.handle(error, { request: httpRequest })
351
+ : new DefaultHttpExceptionFilter().catch(error, { request: httpRequest });
352
+ if (result.headers) {
353
+ for (const [name, value] of Object.entries(result.headers)) {
354
+ reply.header(name, value);
355
+ }
356
+ }
357
+ this.applyResponseHeaders(reply, httpRequest);
358
+ // Returned so Fastify waits for the async onSend hooks instead of sending an empty reply of its own.
359
+ return reply.status(result.status).send(result.body);
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Gives the handler a signal that aborts when the client goes away before the response
365
+ * finished. Streams are also tracked so that stopping the app can abort them.
366
+ */
367
+ private abortOnDisconnect(
368
+ reply: FastifyReply,
369
+ httpRequest: HttpRequest,
370
+ isStream: boolean,
371
+ ): AbortController {
372
+ const controller = new AbortController();
373
+ httpRequest.signal = controller.signal;
374
+ if (isStream) {
375
+ if (this.closing) controller.abort(new Error("The app is stopping"));
376
+ this.openStreams.add(controller);
377
+ }
378
+ reply.raw.once("close", () => {
379
+ this.openStreams.delete(controller);
380
+ if (!reply.raw.writableFinished) {
381
+ controller.abort(new Error("The client disconnected"));
382
+ }
383
+ });
384
+ return controller;
385
+ }
386
+
387
+ private sendEventStream(
388
+ route: MappedRoute,
389
+ reply: FastifyReply,
390
+ response: SseResponse,
391
+ disconnect: AbortController,
392
+ ) {
393
+ let started = false;
394
+ const release = () => {
395
+ response.release().catch((error: unknown) => {
396
+ this.logger.error("Releasing the request scope of an SSE stream failed", error);
397
+ });
398
+ };
399
+ const frames = encodeSse(response.events, {
400
+ signal: disconnect.signal,
401
+ heartbeatMs: route.sse?.heartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS,
402
+ onProducerStopped: (stopped) => void stopped.then(release),
403
+ });
404
+ const guarded = (async function* () {
405
+ started = true;
406
+ yield* frames;
407
+ })();
408
+ // Never pulled if the client left first, in which case nothing else would release the scope.
409
+ reply.raw.once("close", () => {
410
+ if (!started) release();
411
+ });
412
+ return (
413
+ reply
414
+ .status(route.httpCode ?? 200)
415
+ .header("content-type", "text/event-stream; charset=utf-8")
416
+ .header("cache-control", "no-cache, no-transform")
417
+ .header("x-accel-buffering", "no")
418
+ // A stream owns its connection; closing it afterwards lets app stop finish at once instead of waiting out keep-alive.
419
+ .header("connection", "close")
420
+ .send(Readable.from(guarded))
421
+ );
422
+ }
423
+
424
+ private applyResponseHeaders(reply: FastifyReply, httpRequest: HttpRequest) {
425
+ reply.header(REQUEST_ID_HEADER, httpRequest.requestId);
426
+ for (const [name, value] of Object.entries(httpRequest.responseHeaders)) {
427
+ reply.header(name, value);
428
+ }
429
+ }
430
+
431
+ private fastifyToRequest(fastifyRequest: FastifyRequest): HttpRequest {
432
+ const method = fastifyRequest.method;
433
+ const url = fastifyRequest.url;
434
+ const params = fastifyRequest.params as Record<string, string>;
435
+ const query = fastifyRequest.query as Record<string, string>;
436
+ const body = fastifyRequest.body ?? {};
437
+ const headers: Record<string, string> = {};
438
+ for (const [key, value] of Object.entries(fastifyRequest.headers)) {
439
+ if (typeof value === "string") {
440
+ headers[key.toLowerCase()] = value;
441
+ }
442
+ }
443
+ const requestId = resolveRequestId(headers, String(fastifyRequest.id ?? ""));
444
+ return new HttpRequest({
445
+ requestId,
446
+ url,
447
+ method,
448
+ params,
449
+ query,
450
+ body,
451
+ headers,
452
+ });
453
+ }
454
+ }
@@ -0,0 +1,150 @@
1
+ import type { Constructable, Type } from "@decohere/core/reflection";
2
+ import type { HTTPMethod } from "../const.js";
3
+ import type { SseOptions } from "../sse/sse.types.js";
4
+ import type { Pipe } from "./pipes/pipes.js";
5
+
6
+ export type Metadata =
7
+ | "controller"
8
+ | "route"
9
+ | "parameter"
10
+ | "fieldMap"
11
+ | "authorize"
12
+ | "allowAnonymous"
13
+ | "httpCode"
14
+ | "headers"
15
+ | "catch";
16
+
17
+ export type CorsOptions = {
18
+ origin?: boolean | string | string[];
19
+ methods?: string[];
20
+ allowedHeaders?: string[];
21
+ exposedHeaders?: string[];
22
+ credentials?: boolean;
23
+ maxAge?: number;
24
+ };
25
+
26
+ export const RequestParts = {
27
+ query: "query",
28
+ params: "params",
29
+ body: "body",
30
+ principal: "principal",
31
+ signal: "signal",
32
+ lastEventId: "lastEventId",
33
+ } satisfies Record<RequestPart, RequestPart>;
34
+
35
+ export interface HttpProvider {
36
+ start<T extends StartOptions>(options: T): Promise<StartResponse>;
37
+ stop(): Promise<StopResponse>;
38
+ [Symbol.asyncDispose](): Promise<void>;
39
+ }
40
+
41
+ export type RequestPart = "query" | "params" | "body" | "principal" | "signal" | "lastEventId";
42
+
43
+ /** Argument locations filled by the HTTP package itself rather than read from the request payload. */
44
+ export const InjectedParts: readonly RequestPart[] = ["principal", "signal", "lastEventId"];
45
+
46
+ export type MappedRoute = {
47
+ key: string;
48
+ controller: Constructable;
49
+ handler: string;
50
+ routeArgs?: RouteArg[];
51
+ httpCode?: number;
52
+ headers?: Record<string, string>;
53
+ /** Set by `@Sse()`: the handler returns a stream of events instead of a single response. */
54
+ sse?: SseOptions;
55
+ } & RouteMetadata;
56
+
57
+ export type RouteArg = {
58
+ argumentType: Constructable;
59
+ fieldMap?: Map<string, FieldMeta>;
60
+ pipes?: Constructable<Pipe>[];
61
+ } & ArgumentMeta;
62
+
63
+ export class HttpRequest {
64
+ public constructor(data?: Partial<HttpRequest>) {
65
+ Object.assign(this, data);
66
+ }
67
+ public requestId = "";
68
+ public url: string;
69
+ public method: string;
70
+ public params: Record<string, string> = {};
71
+ public query: Record<string, string> = {};
72
+ public body: unknown;
73
+ public headers: Record<string, string> = {};
74
+ /** Aborts when the client disconnects or the app stops. Never aborts if the provider cannot tell. */
75
+ public signal: AbortSignal = new AbortController().signal;
76
+ /** Outbound headers set by middleware; applied by the HTTP provider. */
77
+ public responseHeaders: Record<string, string> = {};
78
+ }
79
+
80
+ export type StartResponse = { addresses: string[] };
81
+ export type StopResponse = { message: string };
82
+ export type StartOptions = { port: number; host?: string };
83
+
84
+ export type ArgumentMeta = {
85
+ argumentIndex: number;
86
+ mappingKey?: string;
87
+ argumentLocation?: RequestPart;
88
+ pipes: Constructable<Pipe>[];
89
+ };
90
+
91
+ export type ControllerMetadata = {
92
+ prefix: string;
93
+ /**
94
+ * `false` leaves this controller's routes out of the global prefix (`withGlobalPrefix`), for
95
+ * paths something outside the app expects at a fixed place: probes, webhooks, metrics. Default
96
+ * `true`.
97
+ */
98
+ globalPrefix?: boolean;
99
+ };
100
+
101
+ export type FieldMeta = {
102
+ fieldKey: string;
103
+ fieldType: Type;
104
+ mappingKey: string;
105
+ fieldLocation?: RequestPart;
106
+ pipes?: Constructable<Pipe>[];
107
+ };
108
+
109
+ export type RouteMetadata = {
110
+ path: string;
111
+ method: HTTPMethod;
112
+ };
113
+ export class RouteConfigurationError extends Error {}
114
+ export class RoutingError extends Error {}
115
+ export class TransformError extends Error {}
116
+
117
+ export class HttpError extends Error {
118
+ public readonly status: number;
119
+ public constructor(message: string, status: number) {
120
+ super(message);
121
+ this.status = status;
122
+ }
123
+ }
124
+
125
+ export class UnauthorizedError extends HttpError {
126
+ public constructor(message = "Unauthorized") {
127
+ super(message, 401);
128
+ }
129
+ }
130
+
131
+ export class ForbiddenError extends HttpError {
132
+ public constructor(message = "Forbidden") {
133
+ super(message, 403);
134
+ }
135
+ }
136
+
137
+ export type ValidationIssue = {
138
+ path: string;
139
+ message: string;
140
+ keyword?: string;
141
+ };
142
+
143
+ export class ValidationError extends HttpError {
144
+ public readonly errors: ValidationIssue[];
145
+
146
+ public constructor(message = "Bad Request", errors: ValidationIssue[] = []) {
147
+ super(message, 400);
148
+ this.errors = errors;
149
+ }
150
+ }