@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,92 @@
1
+ import type { Constructable, Type } from "@decohere/core/reflection";
2
+ import { isPrimitiveOrDateType } from "@decohere/core/reflection";
3
+ import type { IPrincipalProvider } from "../../auth/principal-provider.js";
4
+ import type { FieldMeta, HttpRequest, RouteArg } from "../anvil.types.js";
5
+ import type { Pipe, PipeContext } from "../pipes/pipes.js";
6
+
7
+ export class RouteArgumentResolve {
8
+ public resolveArguments(
9
+ httpRequest: HttpRequest,
10
+ routeArgs: RouteArg[],
11
+ principalProvider?: IPrincipalProvider,
12
+ ) {
13
+ const argsValues = [];
14
+ for (const [argIdx, routeArg] of routeArgs.entries()) {
15
+ const val = this.getValue(httpRequest, routeArg, principalProvider);
16
+ argsValues[argIdx] = val;
17
+ }
18
+ return argsValues;
19
+ }
20
+
21
+ private getValue(
22
+ httpRequest: HttpRequest,
23
+ routeArg: RouteArg,
24
+ principalProvider?: IPrincipalProvider,
25
+ ) {
26
+ switch (routeArg.argumentLocation) {
27
+ case "principal":
28
+ return principalProvider?.currentPrincipal;
29
+ case "signal":
30
+ return httpRequest.signal;
31
+ case "lastEventId":
32
+ return httpRequest.headers["last-event-id"];
33
+ }
34
+ const value = isPrimitiveOrDateType(routeArg.argumentType)
35
+ ? this.mapPrimitive(httpRequest, routeArg)
36
+ : this.mapObject(httpRequest, routeArg);
37
+ return value;
38
+ }
39
+
40
+ private mapPrimitive(httpRequest: HttpRequest, routeArg: RouteArg) {
41
+ const { argumentLocation, mappingKey, pipes } = routeArg;
42
+ const value = httpRequest[argumentLocation][mappingKey];
43
+ const tranformedValue = this.transform(value, pipes, routeArg.fieldMap);
44
+ return tranformedValue;
45
+ }
46
+
47
+ private mapObject(httpRequest: HttpRequest, routeArg: RouteArg) {
48
+ const { argumentType } = routeArg;
49
+ const inst = new argumentType();
50
+ const { fieldMap } = routeArg;
51
+
52
+ for (const [fieldKey, meta] of fieldMap.entries()) {
53
+ const { fieldLocation, mappingKey, pipes } = meta;
54
+ const value = httpRequest[fieldLocation][mappingKey];
55
+ const transformedValue = this.transform(value, pipes, fieldMap);
56
+ inst[fieldKey] = transformedValue;
57
+ }
58
+
59
+ return inst;
60
+ }
61
+
62
+ private transform(
63
+ value: unknown,
64
+ pipes: Constructable<Pipe>[] | undefined,
65
+ fieldMap?: Map<string, FieldMeta>,
66
+ ) {
67
+ let transformed = value;
68
+ if (value == null) {
69
+ return value;
70
+ }
71
+
72
+ const context = this.pipeContext(fieldMap);
73
+ for (const pipeConstructor of pipes ?? []) {
74
+ const pipe = new pipeConstructor();
75
+ transformed = pipe.transform(transformed, context);
76
+ }
77
+ return transformed;
78
+ }
79
+
80
+ private pipeContext(fieldMap?: Map<string, FieldMeta>): PipeContext | undefined {
81
+ if (fieldMap == null || fieldMap.size === 0) {
82
+ return undefined;
83
+ }
84
+ const fieldTypes: Record<string, Type> = {};
85
+ for (const [key, meta] of fieldMap.entries()) {
86
+ if (meta.fieldType != null) {
87
+ fieldTypes[key] = meta.fieldType;
88
+ }
89
+ }
90
+ return Object.keys(fieldTypes).length > 0 ? { fieldTypes } : undefined;
91
+ }
92
+ }
@@ -0,0 +1,229 @@
1
+ import { inject, injectable } from "@decohere/di/decorators";
2
+ import type { Constructable, Field, FunctionType, Type } from "@decohere/core/reflection";
3
+ import {
4
+ getArgumentMeta,
5
+ getFields,
6
+ getFunctionArgs,
7
+ getFunctions,
8
+ getMeta,
9
+ isConstructable,
10
+ isPrimitiveOrDateType,
11
+ } from "@decohere/core/reflection";
12
+ import { HttpOptionsToken, MetadataKeys } from "../../const.js";
13
+ import type { HttpOptions } from "../../http/http.options.js";
14
+ import type { SseOptions } from "../../sse/sse.types.js";
15
+ import type {
16
+ ArgumentMeta,
17
+ ControllerMetadata,
18
+ FieldMeta,
19
+ MappedRoute,
20
+ RouteArg,
21
+ RouteMetadata,
22
+ } from "../anvil.types.js";
23
+ import { InjectedParts, RouteConfigurationError, type RequestPart } from "../anvil.types.js";
24
+ import { applyGlobalPrefix, joinRoutePath, normalizePrefix } from "../path.util.js";
25
+ import { BooleanPipe, DatePipe, JsonPipe, NumberPipe, type Pipe } from "../pipes/pipes.js";
26
+
27
+ @injectable()
28
+ export class RouteRegistry {
29
+ private readonly routeMap = new Map<string, MappedRoute>();
30
+ private readonly httpOptions?: HttpOptions;
31
+
32
+ public constructor(@inject(HttpOptionsToken) httpOptions?: HttpOptions) {
33
+ this.httpOptions = httpOptions;
34
+ }
35
+
36
+ public addController(controller: Constructable) {
37
+ const funcs = getFunctions(controller);
38
+ for (const func of funcs) {
39
+ this.addControllerRoute(controller, func);
40
+ }
41
+ }
42
+
43
+ public get routes(): MapIterator<MappedRoute> {
44
+ return this.routeMap.values();
45
+ }
46
+
47
+ public getRoute(key: string): MappedRoute {
48
+ return this.routeMap.get(key);
49
+ }
50
+
51
+ private addControllerRoute(controller: Constructable, prop: PropertyDescriptor) {
52
+ const func = prop.value;
53
+ const handler = func.name;
54
+ const route = getMeta<RouteMetadata>(MetadataKeys.route, func);
55
+ if (route == null) {
56
+ return;
57
+ }
58
+
59
+ const globalPrefix = this.takesGlobalPrefix(controller)
60
+ ? normalizePrefix(this.httpOptions?.globalPrefix)
61
+ : "";
62
+ const controllerPrefix = this.getControllerPrefix(controller);
63
+ const path = applyGlobalPrefix(globalPrefix, joinRoutePath(controllerPrefix, route.path));
64
+ const { method } = route;
65
+ const key = `${method}|${path}`;
66
+ const existing = this.routeMap.get(key);
67
+ if (existing != null) {
68
+ // Last one used to win silently, leaving the other handler unreachable (#503).
69
+ throw new RouteConfigurationError(
70
+ `Duplicate route ${method.toUpperCase()} ${path}: ${existing.controller.name}.${existing.handler} and ${controller.name}.${handler}`,
71
+ );
72
+ }
73
+ const routeArgs = this.getRouteArguments(controller, func);
74
+ const httpCode = getMeta<number>(MetadataKeys.httpCode, func);
75
+ const headers = getMeta<Record<string, string>>(MetadataKeys.headers, func);
76
+ const sse = getMeta<SseOptions>(MetadataKeys.sse, func);
77
+
78
+ this.routeMap.set(key, {
79
+ key,
80
+ controller,
81
+ handler,
82
+ path,
83
+ method,
84
+ routeArgs,
85
+ httpCode,
86
+ headers,
87
+ sse,
88
+ });
89
+ }
90
+
91
+ /** `@Controller({ globalPrefix: false })` opts out, unless the app put it back under. */
92
+ private takesGlobalPrefix(controller: Constructable): boolean {
93
+ const meta = getMeta<ControllerMetadata>(MetadataKeys.controller, controller.prototype);
94
+ return (
95
+ meta?.globalPrefix !== false ||
96
+ this.httpOptions?.underGlobalPrefix.has(controller) === true
97
+ );
98
+ }
99
+
100
+ private getControllerPrefix(controller: Constructable): string {
101
+ const meta = getMeta<ControllerMetadata>(MetadataKeys.controller, controller.prototype);
102
+ if (meta?.prefix) {
103
+ return normalizePrefix(meta.prefix);
104
+ }
105
+ const controllerName = controller.name.toLowerCase().replace("controller", "");
106
+ return controllerName !== "" ? `/${controllerName}` : "";
107
+ }
108
+
109
+ private getRouteArguments(controller: Constructable, handler: FunctionType): RouteArg[] {
110
+ const types = getFunctionArgs(controller.prototype, handler) ?? [];
111
+ const args: RouteArg[] = [];
112
+ for (const [argumentIndex, argumentType] of types.entries()) {
113
+ const argMeta = getArgumentMeta<ArgumentMeta>(
114
+ MetadataKeys.parameter,
115
+ handler,
116
+ argumentIndex,
117
+ );
118
+ if (argMeta == null && !isConstructable(argumentType)) {
119
+ continue;
120
+ }
121
+ args.push(this.getRouteArgument(argumentType, argumentIndex, handler));
122
+ }
123
+ return args;
124
+ }
125
+
126
+ private getRouteArgument(
127
+ argumentType: Type,
128
+ argumentIndex: number,
129
+ handler: FunctionType,
130
+ ): RouteArg {
131
+ const argMeta = getArgumentMeta<ArgumentMeta>(
132
+ MetadataKeys.parameter,
133
+ handler,
134
+ argumentIndex,
135
+ );
136
+
137
+ // @CurrentUser() / interface params are Object (or other non-DTO types) — no field map.
138
+ if (isInjectedPart(argMeta?.argumentLocation) || !isConstructable(argumentType)) {
139
+ return {
140
+ ...argMeta,
141
+ pipes: [],
142
+ argumentIndex,
143
+ argumentType: (isConstructable(argumentType) ? argumentType : Object) as Constructable,
144
+ fieldMap: new Map(),
145
+ };
146
+ }
147
+
148
+ const pipes = argMeta != null ? this.getDefaultPipes(argumentType) : [];
149
+ const fieldMap = this.getFields(argumentType, argumentIndex, argMeta);
150
+ const routeArg: RouteArg = {
151
+ ...argMeta,
152
+ pipes,
153
+ argumentIndex,
154
+ argumentType,
155
+ fieldMap,
156
+ };
157
+
158
+ return routeArg;
159
+ }
160
+
161
+ private getFields(
162
+ argumentType: Constructable,
163
+ argumentIndex: number,
164
+ argMeta: ArgumentMeta,
165
+ ) {
166
+ const inst = new argumentType();
167
+ const fields = getFields(inst);
168
+ const fieldMap =
169
+ getMeta<Map<string, FieldMeta>>(MetadataKeys.fieldMap, argumentType.prototype) ??
170
+ new Map<string, FieldMeta>();
171
+
172
+ for (const field of fields) {
173
+ const fieldKey = field.key;
174
+ const fieldMeta = fieldMap.get(field.key);
175
+ if (fieldMeta && fieldMeta.fieldLocation == null && argMeta?.argumentLocation == null) {
176
+ throw new RouteConfigurationError(
177
+ `field ${fieldMeta.fieldKey} must specify a location because location was not specified for the route argument at idx ${argumentIndex}`,
178
+ );
179
+ }
180
+ const enriched = this.enrichField(field, fieldMeta, argMeta);
181
+ fieldMap.set(fieldKey, enriched);
182
+ }
183
+
184
+ return fieldMap;
185
+ }
186
+
187
+ private enrichField(field: Field, fieldMeta: FieldMeta, argMeta: ArgumentMeta): FieldMeta {
188
+ const fieldLocation = fieldMeta?.fieldLocation ?? argMeta.argumentLocation;
189
+ const pipes = this.getDefaultPipes(fieldMeta?.fieldType);
190
+ return {
191
+ fieldKey: field.key,
192
+ mappingKey: field.key,
193
+ ...fieldMeta,
194
+ fieldLocation,
195
+ pipes,
196
+ };
197
+ }
198
+
199
+ private getDefaultPipes(argumentType: Type) {
200
+ if (argumentType == null) {
201
+ return [];
202
+ }
203
+
204
+ let pipe: Constructable<Pipe>;
205
+ if (!isPrimitiveOrDateType(argumentType)) {
206
+ pipe = JsonPipe;
207
+ return [pipe];
208
+ }
209
+ switch (argumentType.name) {
210
+ case "Number":
211
+ pipe = NumberPipe;
212
+ break;
213
+ case "Boolean":
214
+ pipe = BooleanPipe;
215
+ break;
216
+ case "Date":
217
+ pipe = DatePipe;
218
+ break;
219
+ default:
220
+ return [];
221
+ }
222
+
223
+ return [pipe];
224
+ }
225
+ }
226
+
227
+ function isInjectedPart(location: RequestPart | undefined): boolean {
228
+ return location != null && InjectedParts.includes(location);
229
+ }
@@ -0,0 +1,166 @@
1
+ import { type LifetimeScope, LifetimeScopeToken } from "@decohere/di";
2
+ import { inject, injectable, optional } from "@decohere/di/decorators";
3
+ import type { Constructable } from "@decohere/core/reflection";
4
+ import { scribe } from "@decohere/scribe";
5
+ import type { IAuthorizationService } from "../../auth/auth.types.js";
6
+ import type { PrincipalProvider } from "../../auth/principal-provider.js";
7
+ import {
8
+ AuthorizationServiceToken,
9
+ HttpOptionsToken,
10
+ IPrincipalProviderToken,
11
+ RouteRegistryToken,
12
+ } from "../../const.js";
13
+ import type { HttpOptions } from "../../http/http.options.js";
14
+ import { validateRequest } from "../../openapi/request-validator.js";
15
+ import { SseResponse } from "../../sse/sse.types.js";
16
+ import type { HttpRequest } from "../anvil.types.js";
17
+ import { RoutingError } from "../anvil.types.js";
18
+ import { getAuthorizeOptions, isAllowAnonymous } from "../decorators/auth.decorator.js";
19
+ import { composeMiddleware, type Middleware } from "../middleware/middleware.js";
20
+ import { RouteArgumentResolve } from "./route-argument-resolver.js";
21
+ import { RouteRegistry } from "./route-registry.js";
22
+
23
+ @injectable()
24
+ export class Router {
25
+ private readonly argResolver = new RouteArgumentResolve();
26
+ private readonly lifetimeScope: LifetimeScope;
27
+ private readonly routeRegistry: RouteRegistry;
28
+ private readonly authService?: IAuthorizationService;
29
+ private readonly httpOptions?: HttpOptions;
30
+ private readonly logger = scribe.logger(Router.name);
31
+
32
+ public constructor(
33
+ @inject(RouteRegistryToken) routeRegistry: RouteRegistry,
34
+ @inject(LifetimeScopeToken) lifetimeScope: LifetimeScope,
35
+ @inject(AuthorizationServiceToken) @optional() authService?: IAuthorizationService,
36
+ @inject(HttpOptionsToken) httpOptions?: HttpOptions,
37
+ ) {
38
+ this.routeRegistry = routeRegistry;
39
+ this.lifetimeScope = lifetimeScope;
40
+ this.authService = authService;
41
+ this.httpOptions = httpOptions;
42
+ this.assertAuthConfiguredForProtectedRoutes();
43
+ }
44
+
45
+ /**
46
+ * Without an authorization service nothing checks tokens, so a route marked @Authorize
47
+ * would be served to anyone. Refuse to start instead of failing open (#500).
48
+ */
49
+ private assertAuthConfiguredForProtectedRoutes(): void {
50
+ if (this.authService != null) {
51
+ return;
52
+ }
53
+ const protectedRoutes = [...this.routeRegistry.routes]
54
+ .filter(({ controller, handler }) =>
55
+ getAuthorizeOptions(controller.prototype, controller.prototype[handler]),
56
+ )
57
+ .map(({ controller, handler }) => `${controller.name}.${String(handler)}`);
58
+ if (protectedRoutes.length > 0) {
59
+ throw new RoutingError(
60
+ `Routes use @Authorize but auth is not configured, so they would be open to anyone: ${protectedRoutes.join(", ")}. Add .withAuth() to the builder, or remove @Authorize.`,
61
+ );
62
+ }
63
+ }
64
+
65
+ public async route(routeKey: string, httpRequest: HttpRequest) {
66
+ const routeConfig = this.routeRegistry.getRoute(routeKey);
67
+ if (!routeConfig) {
68
+ throw new RoutingError(`Route was not configured for key ${routeKey}`);
69
+ }
70
+
71
+ const { controller, handler, routeArgs } = routeConfig;
72
+ // Unnamed: the request id comes from the client, so it is not unique (#497).
73
+ // Disposed before we respond, so async cleanup (e.g. rolling back a DB transaction)
74
+ // finishes first. An SSE route is the exception: its scope lives as long as the stream.
75
+ const scope = this.lifetimeScope.beginScope();
76
+ let scopeHandedOff = false;
77
+
78
+ // Runs inside the middleware chain so rejections (401/403/400) reach middleware such as
79
+ // the access log. Auth comes first so anonymous callers learn nothing about the body (#501).
80
+ const handle = async () => {
81
+ const principal = await this.authorize(routeConfig, httpRequest);
82
+
83
+ if (this.httpOptions?.openapi?.validateRequests !== false && this.httpOptions?.openapi) {
84
+ validateRequest(routeConfig, httpRequest);
85
+ }
86
+
87
+ const provider = scope.resolve<PrincipalProvider>(IPrincipalProviderToken);
88
+ provider.setPrincipal(principal);
89
+
90
+ const controllerInstance = scope.resolve(controller);
91
+ const func = controllerInstance[handler];
92
+ const argValues = this.argResolver.resolveArguments(httpRequest, routeArgs, provider);
93
+ try {
94
+ const result = await func.apply(controllerInstance, argValues);
95
+ if (routeConfig.sse == null) {
96
+ return result;
97
+ }
98
+ if (typeof result?.[Symbol.asyncIterator] !== "function") {
99
+ throw new RoutingError(
100
+ `${controller.name}.${String(handler)} is marked @Sse() so it must return an AsyncIterable (an async generator method works)`,
101
+ );
102
+ }
103
+ scopeHandedOff = true;
104
+ return new SseResponse(result, () => scope[Symbol.asyncDispose]());
105
+ } catch (error) {
106
+ this.logger.error(error.message, error.stack);
107
+ throw error;
108
+ }
109
+ };
110
+
111
+ try {
112
+ const middlewares = this.resolveMiddlewares(scope);
113
+ if (middlewares.length === 0) {
114
+ return await handle();
115
+ }
116
+
117
+ return await composeMiddleware(middlewares, handle, {
118
+ request: httpRequest,
119
+ route: routeConfig,
120
+ });
121
+ } catch (error) {
122
+ // A middleware failed after the handler returned: nobody will consume the stream.
123
+ scopeHandedOff = false;
124
+ throw error;
125
+ } finally {
126
+ if (!scopeHandedOff) {
127
+ await scope[Symbol.asyncDispose]();
128
+ }
129
+ }
130
+ }
131
+
132
+ private resolveMiddlewares(scope: LifetimeScope): Middleware[] {
133
+ const types = this.httpOptions?.middlewareTypes ?? [];
134
+ return types.map((type) => scope.resolve(type as Constructable<Middleware>));
135
+ }
136
+
137
+ private async authorize(
138
+ routeConfig: ReturnType<RouteRegistry["getRoute"]>,
139
+ httpRequest: HttpRequest,
140
+ ): Promise<import("../../auth/auth.types.js").Principal | undefined> {
141
+ // Optional DI may resolve as null when auth is not registered.
142
+ if (this.authService == null) {
143
+ return;
144
+ }
145
+
146
+ const { controller, handler } = routeConfig;
147
+ const handlerFn = controller.prototype[handler];
148
+
149
+ if (isAllowAnonymous(controller.prototype, handlerFn)) {
150
+ return;
151
+ }
152
+
153
+ const authOptions = getAuthorizeOptions(controller.prototype, handlerFn);
154
+ if (authOptions === undefined) {
155
+ // Auth is enabled but the route has no @Authorize — require a valid
156
+ // token (fail closed) without checking a specific ability.
157
+ return this.authService.authenticateRequest(httpRequest);
158
+ }
159
+
160
+ return this.authService.authorizeRequest(
161
+ httpRequest,
162
+ authOptions.action,
163
+ authOptions.subject,
164
+ );
165
+ }
166
+ }
@@ -0,0 +1,41 @@
1
+ import type { FunctionType } from "@decohere/core/reflection";
2
+ import { defineArgumentMeta, defineMeta } from "@decohere/core/reflection";
3
+ import { MetadataKeys } from "../const.js";
4
+ import type { ArgumentMeta, RequestPart } from "../routing/anvil.types.js";
5
+ import type { SseOptions } from "./sse.types.js";
6
+
7
+ /**
8
+ * Mark a route as a server-sent event stream. The method returns an `AsyncIterable` of
9
+ * {@link SseMessage}s (an `async *` method is the usual shape) and the HTTP package writes it as
10
+ * `text/event-stream`. Auth, validation and middleware run first, exactly as on any route,
11
+ * so an unauthorised caller gets a normal 401/403 and no event is ever sent.
12
+ *
13
+ * Combine with `@Get()` (EventSource only issues GET).
14
+ */
15
+ export function Sse(options: SseOptions = {}) {
16
+ return (_target: object, _methodName: string, descriptor: PropertyDescriptor) => {
17
+ defineMeta(MetadataKeys.sse, options, descriptor.value);
18
+ };
19
+ }
20
+
21
+ function injected(location: RequestPart) {
22
+ return () => (target: object, propertyKey: string, parameterIndex: number) => {
23
+ const fn = (target as Record<string, FunctionType>)[propertyKey];
24
+ const meta: ArgumentMeta = {
25
+ argumentIndex: parameterIndex,
26
+ argumentLocation: location,
27
+ mappingKey: undefined,
28
+ pipes: [],
29
+ };
30
+ defineArgumentMeta<ArgumentMeta>(MetadataKeys.parameter, meta, fn, parameterIndex);
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Inject an `AbortSignal` that aborts when the client disconnects or the app stops. Pass it to
36
+ * whatever the producer waits on (timers, subscriptions, queries) so it stops promptly.
37
+ */
38
+ export const Signal = injected("signal");
39
+
40
+ /** Inject the `Last-Event-ID` header the browser sends when it reconnects, or `undefined`. */
41
+ export const LastEventId = injected("lastEventId");
@@ -0,0 +1,86 @@
1
+ import { scribe } from "@decohere/scribe";
2
+ import { sseWire } from "./sse.wire.js";
3
+
4
+ export type SseEncodeOptions = {
5
+ /** Aborted when the client disconnects or the app stops. */
6
+ signal: AbortSignal;
7
+ heartbeatMs: number;
8
+ /**
9
+ * Called once, as the stream ends, with a promise that settles when the producer has
10
+ * actually stopped (its `finally` blocks have run).
11
+ */
12
+ onProducerStopped: (stopped: Promise<void>) => void;
13
+ };
14
+
15
+ const logger = scribe.logger("SseEncoder");
16
+
17
+ type Step =
18
+ | { kind: "item"; result: IteratorResult<unknown> }
19
+ | { kind: "heartbeat" }
20
+ | { kind: "abort" };
21
+
22
+ /**
23
+ * Turn the handler's events into SSE text. Writes an opening comment so the browser sees the
24
+ * response headers at once, a heartbeat whenever the producer is quiet, and stops as soon as the
25
+ * signal aborts, without waiting for a producer that is stuck.
26
+ */
27
+ export async function* encodeSse(
28
+ source: AsyncIterable<unknown>,
29
+ options: SseEncodeOptions,
30
+ ): AsyncGenerator<string> {
31
+ const { signal, heartbeatMs } = options;
32
+ const iterator = source[Symbol.asyncIterator]();
33
+ let onAbort: (() => void) | undefined;
34
+ const abortStep = new Promise<Step>((resolve) => {
35
+ onAbort = () => resolve({ kind: "abort" });
36
+ if (signal.aborted) onAbort();
37
+ else signal.addEventListener("abort", onAbort, { once: true });
38
+ });
39
+ let pending: Promise<Step> | undefined;
40
+ try {
41
+ yield sseWire.formatComment("open");
42
+ while (!signal.aborted) {
43
+ pending ??= iterator.next().then((result): Step => ({ kind: "item", result }));
44
+ let timer: NodeJS.Timeout | undefined;
45
+ const steps: Promise<Step>[] = [pending, abortStep];
46
+ if (heartbeatMs > 0) {
47
+ steps.push(
48
+ new Promise<Step>((resolve) => {
49
+ timer = setTimeout(() => resolve({ kind: "heartbeat" }), heartbeatMs);
50
+ }),
51
+ );
52
+ }
53
+ let step: Step;
54
+ try {
55
+ step = await Promise.race(steps);
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ if (step.kind === "abort") return;
60
+ if (step.kind === "heartbeat") {
61
+ yield sseWire.formatComment("heartbeat");
62
+ continue;
63
+ }
64
+ pending = undefined;
65
+ if (step.result.done) return;
66
+ yield sseWire.formatMessage(step.result.value);
67
+ }
68
+ } catch (error) {
69
+ // Headers are already sent, so the only honest thing left is to log and end the stream.
70
+ if (!signal.aborted) logger.error("SSE producer failed", error);
71
+ } finally {
72
+ if (onAbort) signal.removeEventListener("abort", onAbort);
73
+ // If the producer has not answered its last `next()`, `return()` waits behind it. That is
74
+ // why handlers must pass the signal to whatever they wait on.
75
+ options.onProducerStopped(
76
+ Promise.resolve()
77
+ .then(() => iterator.return?.())
78
+ .then(
79
+ () => undefined,
80
+ (error: unknown) => {
81
+ if (!signal.aborted) logger.error("SSE producer failed while stopping", error);
82
+ },
83
+ ),
84
+ );
85
+ }
86
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * One server-sent event. A bare `string` yielded by a handler is shorthand for `{ data }`.
3
+ *
4
+ * `data` is written as-is when it is a string and as JSON otherwise.
5
+ */
6
+ export type SseMessage<T = unknown> = {
7
+ data: T;
8
+ /** Becomes the `event:` field; the browser dispatches it to `addEventListener(event, ...)`. */
9
+ event?: string;
10
+ /** Becomes the `id:` field; the browser sends the last one back as `Last-Event-ID` on reconnect. */
11
+ id?: string;
12
+ /** Tells the browser how many milliseconds to wait before reconnecting. */
13
+ retry?: number;
14
+ };
15
+
16
+ /** What a `@Sse()` handler returns: any async iterable of messages (an `async *` method is the usual shape). */
17
+ export type SseStream<T = unknown> = AsyncIterable<SseMessage<T> | string>;
18
+
19
+ export type SseOptions = {
20
+ /**
21
+ * Milliseconds of silence before the HTTP package writes a `: heartbeat` comment, which keeps proxies
22
+ * and load balancers from closing an idle connection. Default 15000. `0` turns heartbeats off.
23
+ */
24
+ heartbeatMs?: number;
25
+ };
26
+
27
+ export const DEFAULT_SSE_HEARTBEAT_MS = 15_000;
28
+
29
+ /**
30
+ * What the router hands the HTTP provider for an `@Sse()` route. The request-scoped services the
31
+ * handler uses must stay alive for as long as the stream runs, so the provider calls `release()`
32
+ * once the stream is over, instead of the router disposing them as soon as the handler returns.
33
+ */
34
+ export class SseResponse {
35
+ private released: Promise<void> | undefined;
36
+
37
+ public constructor(
38
+ public readonly events: AsyncIterable<unknown>,
39
+ private readonly onRelease: () => Promise<void>,
40
+ ) {}
41
+
42
+ /** Safe to call more than once. */
43
+ public release(): Promise<void> {
44
+ this.released ??= this.onRelease();
45
+ return this.released;
46
+ }
47
+ }
@@ -0,0 +1,34 @@
1
+ import type { SseMessage } from "./sse.types.js";
2
+
3
+ /** Line breaks would end a field early, so a client-controlled `id` could inject extra fields. */
4
+ function singleLine(value: string): string {
5
+ return value.replace(/[\r\n]+/g, " ");
6
+ }
7
+
8
+ function toMessage(item: unknown): SseMessage {
9
+ if (typeof item === "object" && item !== null && "data" in item) {
10
+ return item as SseMessage;
11
+ }
12
+ return { data: item };
13
+ }
14
+
15
+ /** Encode one event as the text a browser's `EventSource` parses. */
16
+ function formatMessage(item: unknown): string {
17
+ const { data, event, id, retry } = toMessage(item);
18
+ let frame = "";
19
+ if (id != null) frame += `id: ${singleLine(String(id))}\n`;
20
+ if (event != null) frame += `event: ${singleLine(event)}\n`;
21
+ if (retry != null && Number.isFinite(retry)) frame += `retry: ${Math.trunc(retry)}\n`;
22
+ const text = typeof data === "string" ? data : (JSON.stringify(data) ?? "");
23
+ for (const line of text.split(/\r\n|\r|\n/)) {
24
+ frame += `data: ${line}\n`;
25
+ }
26
+ return `${frame}\n`;
27
+ }
28
+
29
+ /** A comment line: ignored by `EventSource`, but it is traffic on the wire. */
30
+ function formatComment(text: string): string {
31
+ return `: ${singleLine(text)}\n\n`;
32
+ }
33
+
34
+ export const sseWire = { formatMessage, formatComment };