@decohere/http 0.0.1-dev.126 → 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 (293) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -13
  3. package/src/app/app.ts +147 -0
  4. package/src/app/module.ts +19 -0
  5. package/src/auth/ability.ts +56 -0
  6. package/src/auth/auth.config.ts +34 -0
  7. package/src/auth/auth.extensions.ts +115 -0
  8. package/src/auth/auth.types.ts +124 -0
  9. package/src/auth/casl-authorization.service.ts +62 -0
  10. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  11. package/src/auth/safe-outbound-url.ts +117 -0
  12. package/src/auth/token-service.ts +192 -0
  13. package/src/builder/builder.ts +118 -0
  14. package/{es6/src/const.js → src/const.ts} +25 -17
  15. package/src/health/health.controller.ts +34 -0
  16. package/src/health/health.extensions.ts +53 -0
  17. package/src/health/readiness.service.ts +33 -0
  18. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  19. package/src/hosting/hosted-service-host.ts +102 -0
  20. package/src/hosting/hosted-service.ts +31 -0
  21. package/src/http/http.compression.ts +38 -0
  22. package/src/http/http.extensions.ts +244 -0
  23. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  24. package/src/http/http.options.ts +41 -0
  25. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  26. package/src/http/http.static.ts +359 -0
  27. package/{es6/src/index.js → src/index.ts} +1 -0
  28. package/src/openapi/index.ts +22 -0
  29. package/src/openapi/openapi.generator.ts +78 -0
  30. package/src/openapi/openapi.types.ts +60 -0
  31. package/src/openapi/request-schema.ts +155 -0
  32. package/src/openapi/request-validator.ts +95 -0
  33. package/src/providers/fastify.provider.ts +454 -0
  34. package/src/routing/anvil.types.ts +150 -0
  35. package/src/routing/decorators/argument.decorator.ts +33 -0
  36. package/src/routing/decorators/auth.decorator.ts +79 -0
  37. package/src/routing/decorators/controller.decorator.ts +9 -0
  38. package/src/routing/decorators/field.decorator.ts +15 -0
  39. package/src/routing/decorators/request.decorator.ts +34 -0
  40. package/src/routing/decorators/response.decorator.ts +18 -0
  41. package/src/routing/decorators/route.decorator.ts +52 -0
  42. package/src/routing/filters/exception-filter-host.ts +90 -0
  43. package/src/routing/filters/exception-filter.ts +29 -0
  44. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  45. package/src/routing/middleware/middleware.ts +26 -0
  46. package/src/routing/middleware/request-id.ts +18 -0
  47. package/src/routing/path.util.ts +38 -0
  48. package/src/routing/pipes/pipes.ts +132 -0
  49. package/src/routing/services/route-argument-resolver.ts +92 -0
  50. package/src/routing/services/route-registry.ts +229 -0
  51. package/src/routing/services/router.ts +166 -0
  52. package/src/sse/sse.decorator.ts +41 -0
  53. package/src/sse/sse.encoder.ts +86 -0
  54. package/src/sse/sse.types.ts +47 -0
  55. package/src/sse/sse.wire.ts +34 -0
  56. package/src/testing/boot-module.ts +63 -0
  57. package/src/testing/sse-reader.ts +120 -0
  58. package/tests/app.hosted-services.spec.ts +292 -0
  59. package/tests/app.shutdown.integration.spec.ts +160 -0
  60. package/tests/app.spec.ts +132 -0
  61. package/tests/auth.ability.spec.ts +48 -0
  62. package/tests/auth.decorators.spec.ts +20 -0
  63. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  64. package/tests/auth.integration.spec.ts +234 -0
  65. package/tests/auth.jwks-cache.spec.ts +52 -0
  66. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  67. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  68. package/tests/auth.oidc.integration.spec.ts +161 -0
  69. package/tests/auth.token-service.spec.ts +251 -0
  70. package/tests/builder.spec.ts +12 -0
  71. package/tests/health.integration.spec.ts +111 -0
  72. package/tests/helpers/listen-ephemeral.ts +13 -0
  73. package/tests/helpers/local-oidc-server.ts +100 -0
  74. package/tests/http.access-log.integration.spec.ts +111 -0
  75. package/tests/http.boolean-query.integration.spec.ts +59 -0
  76. package/tests/http.compression.integration.spec.ts +604 -0
  77. package/tests/http.date-query.integration.spec.ts +71 -0
  78. package/tests/http.global-prefix.integration.spec.ts +284 -0
  79. package/tests/http.number-query.integration.spec.ts +65 -0
  80. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  81. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  82. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  83. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  84. package/tests/http.security.integration.spec.ts +106 -0
  85. package/tests/http.static.integration.spec.ts +847 -0
  86. package/tests/http.static.spec.ts +111 -0
  87. package/tests/http.surface.integration.spec.ts +167 -0
  88. package/tests/openapi.integration.spec.ts +193 -0
  89. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  90. package/tests/routing.decorators.spec.ts +15 -0
  91. package/tests/routing.registry.spec.ts +44 -0
  92. package/tests/routing.resolver.spec.ts +92 -0
  93. package/tests/sse.integration.spec.ts +421 -0
  94. package/tests/sse.wire.spec.ts +33 -0
  95. package/LICENSE +0 -15
  96. package/commonjs/src/app/app.js +0 -166
  97. package/commonjs/src/app/index.js +0 -19
  98. package/commonjs/src/app/module.js +0 -17
  99. package/commonjs/src/auth/ability.js +0 -54
  100. package/commonjs/src/auth/auth.config.js +0 -23
  101. package/commonjs/src/auth/auth.extensions.js +0 -106
  102. package/commonjs/src/auth/auth.types.js +0 -65
  103. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  104. package/commonjs/src/auth/index.js +0 -23
  105. package/commonjs/src/auth/principal-provider.js +0 -19
  106. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  107. package/commonjs/src/auth/token-service.js +0 -151
  108. package/commonjs/src/builder/builder.js +0 -95
  109. package/commonjs/src/builder/index.js +0 -18
  110. package/commonjs/src/const.js +0 -87
  111. package/commonjs/src/health/health.controller.js +0 -75
  112. package/commonjs/src/health/health.extensions.js +0 -50
  113. package/commonjs/src/health/index.js +0 -21
  114. package/commonjs/src/health/readiness.service.js +0 -67
  115. package/commonjs/src/health/readiness.types.js +0 -7
  116. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  117. package/commonjs/src/hosting/hosted-service.js +0 -11
  118. package/commonjs/src/hosting/index.js +0 -19
  119. package/commonjs/src/http/http.compression.js +0 -27
  120. package/commonjs/src/http/http.extensions.js +0 -269
  121. package/commonjs/src/http/http.options.js +0 -26
  122. package/commonjs/src/http/http.options.store.js +0 -20
  123. package/commonjs/src/http/http.security.js +0 -27
  124. package/commonjs/src/http/http.static.js +0 -248
  125. package/commonjs/src/http/index.js +0 -22
  126. package/commonjs/src/index.js +0 -27
  127. package/commonjs/src/openapi/index.js +0 -45
  128. package/commonjs/src/openapi/openapi.generator.js +0 -103
  129. package/commonjs/src/openapi/openapi.types.js +0 -4
  130. package/commonjs/src/openapi/request-schema.js +0 -187
  131. package/commonjs/src/openapi/request-validator.js +0 -85
  132. package/commonjs/src/providers/fastify.provider.js +0 -391
  133. package/commonjs/src/providers/index.js +0 -18
  134. package/commonjs/src/routing/anvil.types.js +0 -99
  135. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  136. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  137. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  138. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  139. package/commonjs/src/routing/decorators/index.js +0 -24
  140. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  141. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  142. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  143. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  144. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  145. package/commonjs/src/routing/index.js +0 -25
  146. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  147. package/commonjs/src/routing/middleware/middleware.js +0 -20
  148. package/commonjs/src/routing/middleware/request-id.js +0 -30
  149. package/commonjs/src/routing/path.util.js +0 -51
  150. package/commonjs/src/routing/pipes/index.js +0 -18
  151. package/commonjs/src/routing/pipes/pipes.js +0 -145
  152. package/commonjs/src/routing/services/index.js +0 -18
  153. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  154. package/commonjs/src/routing/services/route-registry.js +0 -194
  155. package/commonjs/src/routing/services/router.js +0 -158
  156. package/commonjs/src/sse/index.js +0 -26
  157. package/commonjs/src/sse/sse.decorator.js +0 -42
  158. package/commonjs/src/sse/sse.encoder.js +0 -73
  159. package/commonjs/src/sse/sse.types.js +0 -36
  160. package/commonjs/src/sse/sse.wire.js +0 -40
  161. package/commonjs/src/testing/boot-module.js +0 -38
  162. package/commonjs/src/testing/index.js +0 -20
  163. package/commonjs/src/testing/sse-reader.js +0 -98
  164. package/es6/src/app/app.js +0 -148
  165. package/es6/src/app/index.js +0 -2
  166. package/es6/src/app/module.js +0 -7
  167. package/es6/src/auth/ability.js +0 -45
  168. package/es6/src/auth/auth.config.js +0 -19
  169. package/es6/src/auth/auth.extensions.js +0 -83
  170. package/es6/src/auth/auth.types.js +0 -35
  171. package/es6/src/auth/casl-authorization.service.js +0 -40
  172. package/es6/src/auth/index.js +0 -6
  173. package/es6/src/auth/principal-provider.js +0 -14
  174. package/es6/src/auth/safe-outbound-url.js +0 -99
  175. package/es6/src/auth/token-service.js +0 -141
  176. package/es6/src/builder/builder.js +0 -85
  177. package/es6/src/builder/index.js +0 -1
  178. package/es6/src/health/health.controller.js +0 -65
  179. package/es6/src/health/health.extensions.js +0 -40
  180. package/es6/src/health/index.js +0 -4
  181. package/es6/src/health/readiness.service.js +0 -57
  182. package/es6/src/health/readiness.types.js +0 -4
  183. package/es6/src/hosting/hosted-service-host.js +0 -81
  184. package/es6/src/hosting/hosted-service.js +0 -8
  185. package/es6/src/hosting/index.js +0 -2
  186. package/es6/src/http/http.compression.js +0 -9
  187. package/es6/src/http/http.extensions.js +0 -259
  188. package/es6/src/http/http.options.js +0 -16
  189. package/es6/src/http/http.options.store.js +0 -13
  190. package/es6/src/http/http.security.js +0 -9
  191. package/es6/src/http/http.static.js +0 -224
  192. package/es6/src/http/index.js +0 -5
  193. package/es6/src/openapi/index.js +0 -3
  194. package/es6/src/openapi/openapi.generator.js +0 -82
  195. package/es6/src/openapi/openapi.types.js +0 -1
  196. package/es6/src/openapi/request-schema.js +0 -160
  197. package/es6/src/openapi/request-validator.js +0 -71
  198. package/es6/src/providers/fastify.provider.js +0 -376
  199. package/es6/src/providers/index.js +0 -1
  200. package/es6/src/routing/anvil.types.js +0 -57
  201. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  202. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  203. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  204. package/es6/src/routing/decorators/field.decorator.js +0 -15
  205. package/es6/src/routing/decorators/index.js +0 -7
  206. package/es6/src/routing/decorators/request.decorator.js +0 -24
  207. package/es6/src/routing/decorators/response.decorator.js +0 -16
  208. package/es6/src/routing/decorators/route.decorator.js +0 -37
  209. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  210. package/es6/src/routing/filters/exception-filter.js +0 -10
  211. package/es6/src/routing/index.js +0 -8
  212. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  213. package/es6/src/routing/middleware/middleware.js +0 -10
  214. package/es6/src/routing/middleware/request-id.js +0 -14
  215. package/es6/src/routing/path.util.js +0 -33
  216. package/es6/src/routing/pipes/index.js +0 -1
  217. package/es6/src/routing/pipes/pipes.js +0 -118
  218. package/es6/src/routing/services/index.js +0 -1
  219. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  220. package/es6/src/routing/services/route-registry.js +0 -184
  221. package/es6/src/routing/services/router.js +0 -148
  222. package/es6/src/sse/index.js +0 -2
  223. package/es6/src/sse/sse.decorator.js +0 -31
  224. package/es6/src/sse/sse.encoder.js +0 -67
  225. package/es6/src/sse/sse.types.js +0 -22
  226. package/es6/src/sse/sse.wire.js +0 -30
  227. package/es6/src/testing/boot-module.js +0 -35
  228. package/es6/src/testing/index.js +0 -2
  229. package/es6/src/testing/sse-reader.js +0 -91
  230. package/types/src/app/app.d.ts +0 -41
  231. package/types/src/app/module.d.ts +0 -15
  232. package/types/src/auth/ability.d.ts +0 -19
  233. package/types/src/auth/auth.config.d.ts +0 -28
  234. package/types/src/auth/auth.extensions.d.ts +0 -53
  235. package/types/src/auth/auth.types.d.ts +0 -73
  236. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  237. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  238. package/types/src/auth/token-service.d.ts +0 -31
  239. package/types/src/builder/builder.d.ts +0 -36
  240. package/types/src/const.d.ts +0 -32
  241. package/types/src/health/health.controller.d.ts +0 -16
  242. package/types/src/health/health.extensions.d.ts +0 -31
  243. package/types/src/health/readiness.service.d.ts +0 -9
  244. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  245. package/types/src/hosting/hosted-service.d.ts +0 -31
  246. package/types/src/http/http.compression.d.ts +0 -32
  247. package/types/src/http/http.extensions.d.ts +0 -79
  248. package/types/src/http/http.options.d.ts +0 -39
  249. package/types/src/http/http.static.d.ts +0 -160
  250. package/types/src/index.d.ts +0 -10
  251. package/types/src/openapi/index.d.ts +0 -6
  252. package/types/src/openapi/openapi.generator.d.ts +0 -6
  253. package/types/src/openapi/openapi.types.d.ts +0 -58
  254. package/types/src/openapi/request-schema.d.ts +0 -21
  255. package/types/src/openapi/request-validator.d.ts +0 -9
  256. package/types/src/providers/fastify.provider.d.ts +0 -56
  257. package/types/src/routing/anvil.types.d.ts +0 -119
  258. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  259. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  260. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  261. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  262. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  263. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  264. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  265. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  266. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  267. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  268. package/types/src/routing/middleware/middleware.d.ts +0 -10
  269. package/types/src/routing/middleware/request-id.d.ts +0 -6
  270. package/types/src/routing/path.util.d.ts +0 -9
  271. package/types/src/routing/pipes/pipes.d.ts +0 -21
  272. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  273. package/types/src/routing/services/route-registry.d.ts +0 -20
  274. package/types/src/routing/services/router.d.ts +0 -22
  275. package/types/src/sse/sse.decorator.d.ts +0 -17
  276. package/types/src/sse/sse.encoder.d.ts +0 -16
  277. package/types/src/sse/sse.types.d.ts +0 -37
  278. package/types/src/sse/sse.wire.d.ts +0 -9
  279. package/types/src/testing/boot-module.d.ts +0 -34
  280. package/types/src/testing/sse-reader.d.ts +0 -29
  281. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  282. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  283. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  284. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  285. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  286. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  287. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  288. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  289. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  290. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  291. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  292. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  293. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
@@ -0,0 +1,359 @@
1
+ import path from "node:path";
2
+ import picomatch from "picomatch";
3
+ import { isDevelopmentNodeEnv } from "../auth/auth.types.js";
4
+
5
+ /**
6
+ * Serving a module's built UI from its API (`builder.withStatic`). Everything here is
7
+ * provider-neutral: the options, the caching rules and the missing-directory policy.
8
+ * Only the HTTP provider knows how to put files on the wire. See ADR 0007.
9
+ */
10
+
11
+ /** Cache header for files whose name carries a content hash. The name changes when the content does. */
12
+ export const STATIC_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
13
+
14
+ /** Cache header for files that must be revalidated on every use (the entry points of a UI). */
15
+ export const STATIC_NO_CACHE_CONTROL = "no-cache";
16
+
17
+ /** Cache header for every other file: cacheable, but always revalidated (ETag / Last-Modified). */
18
+ export const STATIC_DEFAULT_CACHE_CONTROL = "public, max-age=0, must-revalidate";
19
+
20
+ /**
21
+ * The entry points of a UI. `index.html` names the hashed bundles, and a module-federation host
22
+ * loads `remoteEntry.js` by a fixed name to find the current remote, so neither can be cached.
23
+ */
24
+ export const STATIC_DEFAULT_NO_CACHE: readonly string[] = ["index.html", "remoteEntry.js"];
25
+
26
+ /**
27
+ * A build tool's hashed file name: `index-DiwrgTda.js`, `chunk.3f9a8c1b.css`, `app-abc123de.js.map`.
28
+ * The hash is 8 or more letters, digits or `_` after a `-` or `.`, and holds a digit or a lowercase
29
+ * letter followed by an uppercase one. Plain words (`logo-background.png`) are not hashes.
30
+ */
31
+ export const STATIC_DEFAULT_IMMUTABLE =
32
+ /[.-](?=[A-Za-z0-9_]*(?:\d|[a-z][A-Z]))[A-Za-z0-9_]{8,}\.[A-Za-z0-9]+(?:\.map)?$/;
33
+
34
+ export type StaticOptions = {
35
+ /**
36
+ * Serve `index.html` for an unknown path under the prefix, so a client-side route survives a
37
+ * reload. Only for GET and HEAD, and never for a path that looks like a file (has an
38
+ * extension), so a missing asset stays a 404. Defaults to `true`.
39
+ */
40
+ spaFallback?: boolean;
41
+ /**
42
+ * Path patterns this mount never answers, neither a file nor the fallback: the request gets the
43
+ * normal JSON 404. Globs on the full request path (not relative to the prefix), for example
44
+ * `["/api/**", "/docs/**"]`: `*` is any run of characters inside one segment, `**` crosses
45
+ * segments, `?` is one character. `/api/**` also covers `/api` itself. Rarely needed: a path
46
+ * that starts with the first segment of a registered controller route is already never
47
+ * answered with the fallback (see `staticFiles.isUnderRoute`).
48
+ */
49
+ exclude?: string[];
50
+ /** The file served for the prefix itself and for the fallback. Defaults to `index.html`. */
51
+ index?: string;
52
+ /**
53
+ * Files sent with `Cache-Control: no-cache`, by file name (`index.html`) or by path relative to
54
+ * the directory (`remote/remoteEntry.js`). Replaces the default `["index.html", "remoteEntry.js"]`,
55
+ * so list `index.html` again if you override it.
56
+ */
57
+ noCache?: string[];
58
+ /**
59
+ * Files whose name holds a content hash get `public, max-age=31536000, immutable`. Replace the
60
+ * pattern if your build names files differently (it is tested against the path relative to the
61
+ * directory), or pass `false` to give no file the immutable header.
62
+ */
63
+ immutable?: RegExp | false;
64
+ /** `Cache-Control` for every other file. Defaults to `public, max-age=0, must-revalidate`. */
65
+ cacheControl?: string;
66
+ /**
67
+ * What to do when the directory does not exist at start. `"fail"` stops the app with an error;
68
+ * `"warn"` logs a warning and serves nothing, so the API still boots before the UI is built.
69
+ * Defaults to `"warn"` when NODE_ENV is `development` or `dev`, and to `"fail"` otherwise.
70
+ */
71
+ onMissingDirectory?: "fail" | "warn";
72
+ };
73
+
74
+ /** A `withStatic` call, as the builder keeps it. */
75
+ export type StaticMount = {
76
+ /** Normalised: starts and ends with `/`, for example `/ui/`. */
77
+ prefix: string;
78
+ /** As given by the caller, resolved from the process working directory when the app starts. */
79
+ directory: string;
80
+ options: StaticOptions;
81
+ };
82
+
83
+ /** A mount the provider can register: the directory exists and every option has its value. */
84
+ export type ResolvedStaticMount = {
85
+ prefix: string;
86
+ /** Absolute path of the directory. */
87
+ root: string;
88
+ spaFallback: boolean;
89
+ index: string;
90
+ noCache: readonly string[];
91
+ immutable: RegExp | false;
92
+ cacheControl: string;
93
+ exclude: readonly string[];
94
+ };
95
+
96
+ /** `"ui"`, `"/ui"` and `"/ui/"` all mean `/ui/`; `"/"` is the root. Refuses an empty prefix. */
97
+ function normalizePrefix(prefix: string): string {
98
+ const segments = prefix.split("/").filter((segment) => segment !== "");
99
+ if (segments.length === 0) {
100
+ if (prefix.trim() === "") {
101
+ throw new Error('withStatic needs a prefix, such as "/ui/" or "/" for the root.');
102
+ }
103
+ return "/";
104
+ }
105
+ if (
106
+ segments.some((segment) => segment === "." || segment === ".." || /[*:?]/.test(segment))
107
+ ) {
108
+ throw new Error(
109
+ `withStatic prefix "${prefix}" must be plain path segments, such as "/ui/".`,
110
+ );
111
+ }
112
+ return `/${segments.join("/")}/`;
113
+ }
114
+
115
+ function toMount(prefix: string, directory: string, options: StaticOptions = {}): StaticMount {
116
+ if (directory.trim() === "") {
117
+ throw new Error(`withStatic("${prefix}") needs the directory that holds the built files.`);
118
+ }
119
+ return { prefix: normalizePrefix(prefix), directory, options };
120
+ }
121
+
122
+ type DirectoryProbe = (absolutePath: string) => Promise<boolean>;
123
+
124
+ export type ResolveStaticOptions = {
125
+ /** Where relative directories are resolved from. Defaults to the process working directory. */
126
+ cwd?: string;
127
+ nodeEnv?: string;
128
+ directoryExists: DirectoryProbe;
129
+ warn: (message: string) => void;
130
+ };
131
+
132
+ /**
133
+ * Checks every mount at start. Returns the ones that can be served; a missing directory fails or
134
+ * warns according to the mount's `onMissingDirectory`. Two mounts on one prefix always fail.
135
+ */
136
+ async function resolve(
137
+ mounts: readonly StaticMount[],
138
+ env: ResolveStaticOptions,
139
+ ): Promise<ResolvedStaticMount[]> {
140
+ const cwd = env.cwd ?? process.cwd();
141
+ const resolved: ResolvedStaticMount[] = [];
142
+ const seen = new Set<string>();
143
+ for (const mount of mounts) {
144
+ if (seen.has(mount.prefix)) {
145
+ throw new Error(`withStatic: ${mount.prefix} is mounted twice.`);
146
+ }
147
+ seen.add(mount.prefix);
148
+
149
+ const root = path.resolve(cwd, mount.directory);
150
+ if (!(await env.directoryExists(root))) {
151
+ const policy =
152
+ mount.options.onMissingDirectory ??
153
+ (isDevelopmentNodeEnv(env.nodeEnv) ? "warn" : "fail");
154
+ const message =
155
+ `withStatic("${mount.prefix}", "${mount.directory}"): ${root} does not exist. ` +
156
+ "Build the UI first, or run the app from the folder that holds it.";
157
+ if (policy === "fail") {
158
+ throw new Error(message);
159
+ }
160
+ env.warn(
161
+ `${message} ${mount.prefix} will answer 404 until it is built and the app restarts.`,
162
+ );
163
+ continue;
164
+ }
165
+
166
+ const options = mount.options;
167
+ resolved.push({
168
+ prefix: mount.prefix,
169
+ root,
170
+ spaFallback: options.spaFallback ?? true,
171
+ index: options.index ?? "index.html",
172
+ noCache: options.noCache ?? STATIC_DEFAULT_NO_CACHE,
173
+ immutable: options.immutable ?? STATIC_DEFAULT_IMMUTABLE,
174
+ cacheControl: options.cacheControl ?? STATIC_DEFAULT_CACHE_CONTROL,
175
+ exclude: options.exclude ?? [],
176
+ });
177
+ }
178
+ return resolved;
179
+ }
180
+
181
+ /** `Cache-Control` for a file, given its path relative to the mount's directory. */
182
+ function cacheControlFor(mount: ResolvedStaticMount, relativePath: string): string {
183
+ const posix = relativePath.split(path.sep).join("/");
184
+ const name = path.posix.basename(posix);
185
+ if (mount.noCache.some((entry) => entry === name || entry === posix)) {
186
+ return STATIC_NO_CACHE_CONTROL;
187
+ }
188
+ if (mount.immutable !== false && mount.immutable.test(posix)) {
189
+ return STATIC_IMMUTABLE_CACHE_CONTROL;
190
+ }
191
+ return mount.cacheControl;
192
+ }
193
+
194
+ /**
195
+ * Does this path look like a file: an extension on the last segment, or a dotfile anywhere?
196
+ * Then a miss is a real 404, not a client-side route.
197
+ */
198
+ function looksLikeFile(pathname: string): boolean {
199
+ const segments = pathname.split("/").filter((segment) => segment !== "");
200
+ if (segments.some((segment) => segment.startsWith("."))) {
201
+ return true;
202
+ }
203
+ const last = pathname.endsWith("/") ? undefined : segments.at(-1);
204
+ return last?.includes(".") === true;
205
+ }
206
+
207
+ /** The request path as the mount sees it: no query, decoded, repeated slashes collapsed. */
208
+ function normalizePathname(url: string): string {
209
+ const raw = url.split("?")[0] ?? "";
210
+ let decoded = raw;
211
+ try {
212
+ decoded = decodeURIComponent(raw);
213
+ } catch {
214
+ // Keep the raw text: a malformed escape is matched as written.
215
+ }
216
+ return decoded.replace(/\/{2,}/g, "/");
217
+ }
218
+
219
+ /** Builds the test for a mount's `exclude` patterns. Matches on the normalised path. */
220
+ function excluder(patterns: readonly string[]): (pathname: string) => boolean {
221
+ if (patterns.length === 0) {
222
+ return () => false;
223
+ }
224
+ const matches = picomatch([...patterns], { dot: true });
225
+ return (pathname) => matches(pathname);
226
+ }
227
+
228
+ /**
229
+ * The first path segment of every registered route, for {@link isUnderRoute}. A segment that is a
230
+ * parameter or wildcard (`:id`, `*`) says nothing about the path and is skipped.
231
+ */
232
+ function routeSegments(routePaths: Iterable<string>): Set<string> {
233
+ const segments = new Set<string>();
234
+ for (const routePath of routePaths) {
235
+ const first = routePath.split("/").find((segment) => segment !== "");
236
+ if (first != null && !/[:*]/.test(first)) {
237
+ segments.add(first);
238
+ }
239
+ }
240
+ return segments;
241
+ }
242
+
243
+ /**
244
+ * Is this path under one of the app's own routes? True when its first segment is the first
245
+ * segment of a registered route: with a controller route `/lp/portfolio`, `/lp/portfolo` and
246
+ * `/lp/anything/else` belong to the API and keep the JSON 404, while `/lpx` does not.
247
+ */
248
+ function isUnderRoute(pathname: string, segments: ReadonlySet<string>): boolean {
249
+ const first = pathname.split("/").find((segment) => segment !== "");
250
+ return first != null && segments.has(first);
251
+ }
252
+
253
+ /** Is `pathname` the path `base` or below it? `/api` holds `/api` and `/api/x`, not `/apiary`. */
254
+ function isWithin(pathname: string, base: string): boolean {
255
+ const trimmed = base.replace(/\/+$/, "");
256
+ return pathname === trimmed || pathname.startsWith(`${trimmed}/`);
257
+ }
258
+
259
+ /**
260
+ * The literal start of a route path, up to its first parameter or wildcard: `/webhooks/:id` gives
261
+ * `/webhooks`, `/:id` gives `""`.
262
+ */
263
+ function staticStem(routePath: string): string {
264
+ const stem: string[] = [];
265
+ for (const segment of routePath.split("/")) {
266
+ if (segment.startsWith(":") || segment.includes("*")) {
267
+ break;
268
+ }
269
+ if (segment !== "") {
270
+ stem.push(segment);
271
+ }
272
+ }
273
+ return stem.length === 0 ? "" : `/${stem.join("/")}`;
274
+ }
275
+
276
+ /**
277
+ * What the fallback treats as the API, as a test on the normalised path. With a global prefix it
278
+ * is the prefix, the OpenAPI path and every route left outside the prefix
279
+ * (`@Controller({ globalPrefix: false })`, such as `/health`), each with the paths below it, so a
280
+ * client route may share a first segment with a controller. Without a prefix it is
281
+ * {@link isUnderRoute} (the first segment of every registered route), the safe guess when nothing
282
+ * says where the API lives.
283
+ */
284
+ function apiBoundary(boundary: {
285
+ /** Normalised global prefix (`/api`), or `""` when there is none. */
286
+ globalPrefix: string;
287
+ openApiPath?: string;
288
+ /** Every registered route path, global prefix included. */
289
+ routePaths: Iterable<string>;
290
+ }): (pathname: string) => boolean {
291
+ if (boundary.globalPrefix === "") {
292
+ const segments = routeSegments([
293
+ ...boundary.routePaths,
294
+ ...(boundary.openApiPath == null ? [] : [boundary.openApiPath]),
295
+ ]);
296
+ return (pathname) => isUnderRoute(pathname, segments);
297
+ }
298
+ const outside = [...boundary.routePaths]
299
+ .filter((routePath) => !isWithin(routePath, boundary.globalPrefix))
300
+ .map(staticStem)
301
+ .filter((stem) => stem !== "");
302
+ const bases = [boundary.globalPrefix, boundary.openApiPath, ...outside].filter(
303
+ (base): base is string => base != null,
304
+ );
305
+ return (pathname) => bases.some((base) => isWithin(pathname, base));
306
+ }
307
+
308
+ /** Does the `Accept` header ask for HTML (a browser navigation)? `text/html;q=0` does not. */
309
+ function acceptsHtml(accept: string | undefined): boolean {
310
+ if (accept == null) {
311
+ return false;
312
+ }
313
+ return accept.split(",").some((entry) => {
314
+ const [type = "", ...params] = entry.split(";").map((part) => part.trim().toLowerCase());
315
+ if (type !== "text/html") {
316
+ return false;
317
+ }
318
+ const quality = params.find((param) => param.startsWith("q="));
319
+ return quality == null || Number.parseFloat(quality.slice(2)) > 0;
320
+ });
321
+ }
322
+
323
+ export type FallbackRequest = {
324
+ method: string;
325
+ /** Normalised, see `normalizePathname`. */
326
+ pathname: string;
327
+ accept: string | undefined;
328
+ };
329
+
330
+ /**
331
+ * Should an unknown path get the app's `index.html`? Only a browser navigation: GET or HEAD, no
332
+ * file extension and no dotfile in the path, an `Accept` that includes `text/html`, and not under
333
+ * the API (see {@link apiBoundary}). Everything else keeps the normal JSON 404.
334
+ */
335
+ function shouldFallBack(
336
+ request: FallbackRequest,
337
+ belongsToApi: (pathname: string) => boolean,
338
+ ): boolean {
339
+ return (
340
+ (request.method === "GET" || request.method === "HEAD") &&
341
+ acceptsHtml(request.accept) &&
342
+ !looksLikeFile(request.pathname) &&
343
+ !belongsToApi(request.pathname)
344
+ );
345
+ }
346
+
347
+ export const staticFiles = {
348
+ toMount,
349
+ resolve,
350
+ cacheControlFor,
351
+ looksLikeFile,
352
+ normalizePathname,
353
+ excluder,
354
+ routeSegments,
355
+ isUnderRoute,
356
+ apiBoundary,
357
+ acceptsHtml,
358
+ shouldFallBack,
359
+ };
@@ -1,4 +1,5 @@
1
1
  // Resolve StartOptions re-export ambiguity (app vs routing) — prefer app's shape
2
+ export type { StartOptions } from "./app/index.js";
2
3
  export * from "./app/index.js";
3
4
  export * from "./auth/index.js";
4
5
  export * from "./builder/index.js";
@@ -0,0 +1,22 @@
1
+ export type { ValidationIssue } from "../routing/anvil.types.js";
2
+ export {
3
+ generateOpenApiDocument,
4
+ resolveOpenApiPath,
5
+ toOpenApiPath,
6
+ } from "./openapi.generator.js";
7
+ export type {
8
+ JsonSchema,
9
+ OpenApiDocument,
10
+ OpenApiOperation,
11
+ OpenApiOptions,
12
+ OpenApiParameter,
13
+ } from "./openapi.types.js";
14
+ export type { RouteRequestSchemas } from "./request-schema.js";
15
+ export {
16
+ buildRouteRequestSchemas,
17
+ mergeObjectSchemas,
18
+ objectArgToParts,
19
+ pathParamsToSchema,
20
+ typeToSchema,
21
+ } from "./request-schema.js";
22
+ export { ValidationError, validateRequest } from "./request-validator.js";
@@ -0,0 +1,78 @@
1
+ import type { MappedRoute } from "../routing/anvil.types.js";
2
+ import type { RouteRegistry } from "../routing/services/route-registry.js";
3
+ import type { OpenApiDocument, OpenApiOperation, OpenApiOptions } from "./openapi.types.js";
4
+ import { buildRouteRequestSchemas } from "./request-schema.js";
5
+
6
+ const DEFAULT_SPEC_PATH = "/openapi.json";
7
+
8
+ export function resolveOpenApiPath(options: OpenApiOptions): string {
9
+ const path = options.path?.trim() || DEFAULT_SPEC_PATH;
10
+ return path.startsWith("/") ? path : `/${path}`;
11
+ }
12
+
13
+ /** Convert Fastify-style `:id` path params to OpenAPI `{id}`. */
14
+ export function toOpenApiPath(fastifyPath: string): string {
15
+ return fastifyPath.replace(/:([A-Za-z_][\w]*)/g, "{$1}");
16
+ }
17
+
18
+ export function generateOpenApiDocument(
19
+ registry: RouteRegistry,
20
+ options: OpenApiOptions,
21
+ ): OpenApiDocument {
22
+ const paths: OpenApiDocument["paths"] = {};
23
+
24
+ for (const route of registry.routes) {
25
+ const path = toOpenApiPath(route.path);
26
+ const method = route.method.toLowerCase();
27
+ let pathItem = paths[path];
28
+ if (pathItem == null) {
29
+ pathItem = {};
30
+ paths[path] = pathItem;
31
+ }
32
+ pathItem[method] = buildOperation(route);
33
+ }
34
+
35
+ const document: OpenApiDocument = {
36
+ openapi: "3.0.3",
37
+ info: options.info,
38
+ paths,
39
+ };
40
+ if (options.servers?.length) {
41
+ document.servers = options.servers;
42
+ }
43
+ return document;
44
+ }
45
+
46
+ function buildOperation(route: MappedRoute): OpenApiOperation {
47
+ const { queryParams, pathParams, body: bodySchema } = buildRouteRequestSchemas(route);
48
+ const parameters = [...queryParams, ...pathParams];
49
+
50
+ const status = String(route.httpCode ?? 200);
51
+ const operation: OpenApiOperation = {
52
+ operationId: `${route.controller.name}.${route.handler}`,
53
+ tags: [route.controller.name],
54
+ responses: {
55
+ [status]: route.sse
56
+ ? {
57
+ description: "Server-sent event stream",
58
+ content: { "text/event-stream": { schema: { type: "string" } } },
59
+ }
60
+ : { description: "Successful response" },
61
+ },
62
+ };
63
+ if (route.routeArgs?.some((arg) => arg.argumentLocation === "lastEventId")) {
64
+ parameters.push({ name: "Last-Event-ID", in: "header", schema: { type: "string" } });
65
+ }
66
+ if (parameters.length > 0) {
67
+ operation.parameters = parameters;
68
+ }
69
+ if (bodySchema != null) {
70
+ operation.requestBody = {
71
+ required: true,
72
+ content: {
73
+ "application/json": { schema: bodySchema },
74
+ },
75
+ };
76
+ }
77
+ return operation;
78
+ }
@@ -0,0 +1,60 @@
1
+ export type OpenApiInfo = {
2
+ title: string;
3
+ version: string;
4
+ description?: string;
5
+ };
6
+
7
+ export type OpenApiServer = {
8
+ url: string;
9
+ description?: string;
10
+ };
11
+
12
+ export type OpenApiOptions = {
13
+ /** Defaults to `/openapi.json`. Absolute Fastify path (not affected by globalPrefix). */
14
+ path?: string;
15
+ info: OpenApiInfo;
16
+ servers?: OpenApiServer[];
17
+ /** When true (default), validate request params/query/body before the handler. */
18
+ validateRequests?: boolean;
19
+ };
20
+
21
+ export type JsonSchema = {
22
+ type?: string;
23
+ properties?: Record<string, JsonSchema>;
24
+ required?: string[];
25
+ items?: JsonSchema;
26
+ additionalProperties?: boolean | JsonSchema;
27
+ format?: string;
28
+ description?: string;
29
+ minLength?: number;
30
+ maxLength?: number;
31
+ };
32
+
33
+ export type OpenApiParameter = {
34
+ name: string;
35
+ in: "query" | "path" | "header" | "cookie";
36
+ required?: boolean;
37
+ schema?: JsonSchema;
38
+ };
39
+
40
+ export type OpenApiOperation = {
41
+ operationId?: string;
42
+ summary?: string;
43
+ tags?: string[];
44
+ parameters?: OpenApiParameter[];
45
+ requestBody?: {
46
+ required?: boolean;
47
+ content: Record<string, { schema: JsonSchema }>;
48
+ };
49
+ responses: Record<
50
+ string,
51
+ { description: string; content?: Record<string, { schema: JsonSchema }> }
52
+ >;
53
+ };
54
+
55
+ export type OpenApiDocument = {
56
+ openapi: "3.0.3";
57
+ info: OpenApiInfo;
58
+ servers?: OpenApiServer[];
59
+ paths: Record<string, Partial<Record<string, OpenApiOperation>>>;
60
+ };
@@ -0,0 +1,155 @@
1
+ import { isPrimitiveOrDateType } from "@decohere/core/reflection";
2
+ import { InjectedParts, type MappedRoute, type RouteArg } from "../routing/anvil.types.js";
3
+ import type { JsonSchema, OpenApiParameter } from "./openapi.types.js";
4
+
5
+ /**
6
+ * Request shapes derived from route / `@Field` metadata (same source as OpenAPI).
7
+ * Used by the document generator and the AJV request validator.
8
+ */
9
+ export type RouteRequestSchemas = {
10
+ queryParams: OpenApiParameter[];
11
+ pathParams: OpenApiParameter[];
12
+ body?: JsonSchema;
13
+ };
14
+
15
+ export function buildRouteRequestSchemas(route: MappedRoute): RouteRequestSchemas {
16
+ const queryParams: OpenApiParameter[] = [];
17
+ const pathParams: OpenApiParameter[] = [];
18
+ let bodySchema: JsonSchema | undefined;
19
+
20
+ for (const routeArg of route.routeArgs ?? []) {
21
+ if (
22
+ routeArg.argumentLocation != null &&
23
+ InjectedParts.includes(routeArg.argumentLocation)
24
+ ) {
25
+ continue;
26
+ }
27
+
28
+ if (isPrimitiveOrDateType(routeArg.argumentType)) {
29
+ const location = routeArg.argumentLocation;
30
+ if (location === "query" || location === "params") {
31
+ const param: OpenApiParameter = {
32
+ name: routeArg.mappingKey ?? "value",
33
+ in: location === "params" ? "path" : "query",
34
+ required: location === "params",
35
+ schema: typeToSchema(routeArg.argumentType.name),
36
+ };
37
+ if (location === "params") {
38
+ pathParams.push(param);
39
+ } else {
40
+ queryParams.push(param);
41
+ }
42
+ }
43
+ continue;
44
+ }
45
+
46
+ const parts = objectArgToParts(routeArg);
47
+ queryParams.push(...parts.queryParams);
48
+ pathParams.push(...parts.pathParams);
49
+ if (parts.body != null) {
50
+ bodySchema = mergeObjectSchemas(bodySchema, parts.body);
51
+ }
52
+ }
53
+
54
+ return { queryParams, pathParams, body: bodySchema };
55
+ }
56
+
57
+ export function objectArgToParts(routeArg: RouteArg): {
58
+ queryParams: OpenApiParameter[];
59
+ pathParams: OpenApiParameter[];
60
+ body?: JsonSchema;
61
+ } {
62
+ const queryParams: OpenApiParameter[] = [];
63
+ const pathParams: OpenApiParameter[] = [];
64
+ const bodyProperties: Record<string, JsonSchema> = {};
65
+ const bodyRequired: string[] = [];
66
+
67
+ const argLocation = routeArg.argumentLocation;
68
+ for (const [, meta] of routeArg.fieldMap?.entries() ?? []) {
69
+ const location = meta.fieldLocation ?? argLocation;
70
+ const name = meta.mappingKey || meta.fieldKey;
71
+ const schema = typeToSchema(meta.fieldType?.name);
72
+
73
+ if (location === "query") {
74
+ queryParams.push({ name, in: "query", required: false, schema });
75
+ } else if (location === "params") {
76
+ pathParams.push({ name, in: "path", required: true, schema });
77
+ } else if (location === "body" || (location == null && argLocation === "body")) {
78
+ bodyProperties[name] = schema;
79
+ bodyRequired.push(name);
80
+ } else if (location == null && argLocation == null) {
81
+ // Unlocated object fields default to body for OpenAPI (common DTO pattern).
82
+ bodyProperties[name] = schema;
83
+ bodyRequired.push(name);
84
+ }
85
+ }
86
+
87
+ // Whole-object body argument with no fields still advertises an object body.
88
+ if (argLocation === "body" && Object.keys(bodyProperties).length === 0) {
89
+ return {
90
+ queryParams,
91
+ pathParams,
92
+ body: { type: "object", additionalProperties: true },
93
+ };
94
+ }
95
+
96
+ const body =
97
+ Object.keys(bodyProperties).length > 0
98
+ ? {
99
+ type: "object",
100
+ properties: bodyProperties,
101
+ required: bodyRequired,
102
+ additionalProperties: false,
103
+ }
104
+ : undefined;
105
+
106
+ return { queryParams, pathParams, body };
107
+ }
108
+
109
+ export function mergeObjectSchemas(a: JsonSchema | undefined, b: JsonSchema): JsonSchema {
110
+ if (a == null) {
111
+ return b;
112
+ }
113
+ return {
114
+ type: "object",
115
+ properties: { ...a.properties, ...b.properties },
116
+ required: [...new Set([...(a.required ?? []), ...(b.required ?? [])])],
117
+ additionalProperties: false,
118
+ };
119
+ }
120
+
121
+ export function typeToSchema(typeName?: string): JsonSchema {
122
+ switch (typeName) {
123
+ case "Number":
124
+ return { type: "number" };
125
+ case "Boolean":
126
+ return { type: "boolean" };
127
+ case "Date":
128
+ return { type: "string", format: "date-time" };
129
+ case "String":
130
+ return { type: "string" };
131
+ default:
132
+ return {};
133
+ }
134
+ }
135
+
136
+ /** JSON Schema object for required path params (HTTP path values are always strings). */
137
+ export function pathParamsToSchema(params: OpenApiParameter[]): JsonSchema | undefined {
138
+ if (params.length === 0) {
139
+ return undefined;
140
+ }
141
+ const properties: Record<string, JsonSchema> = {};
142
+ const required: string[] = [];
143
+ for (const param of params) {
144
+ properties[param.name] = { type: "string", minLength: 1 };
145
+ if (param.required !== false) {
146
+ required.push(param.name);
147
+ }
148
+ }
149
+ return {
150
+ type: "object",
151
+ properties,
152
+ required,
153
+ additionalProperties: true,
154
+ };
155
+ }