@decohere/http 0.0.1-dev.116

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 (206) hide show
  1. package/README.md +246 -0
  2. package/commonjs/src/app/app.js +166 -0
  3. package/commonjs/src/app/index.js +19 -0
  4. package/commonjs/src/app/module.js +17 -0
  5. package/commonjs/src/auth/ability.js +54 -0
  6. package/commonjs/src/auth/auth.config.js +23 -0
  7. package/commonjs/src/auth/auth.extensions.js +106 -0
  8. package/commonjs/src/auth/auth.types.js +65 -0
  9. package/commonjs/src/auth/casl-authorization.service.js +36 -0
  10. package/commonjs/src/auth/index.js +23 -0
  11. package/commonjs/src/auth/principal-provider.js +19 -0
  12. package/commonjs/src/auth/safe-outbound-url.js +102 -0
  13. package/commonjs/src/auth/token-service.js +151 -0
  14. package/commonjs/src/builder/builder.js +95 -0
  15. package/commonjs/src/builder/index.js +18 -0
  16. package/commonjs/src/const.js +87 -0
  17. package/commonjs/src/health/health.controller.js +75 -0
  18. package/commonjs/src/health/health.extensions.js +50 -0
  19. package/commonjs/src/health/index.js +21 -0
  20. package/commonjs/src/health/readiness.service.js +67 -0
  21. package/commonjs/src/health/readiness.types.js +7 -0
  22. package/commonjs/src/hosting/hosted-service-host.js +88 -0
  23. package/commonjs/src/hosting/hosted-service.js +11 -0
  24. package/commonjs/src/hosting/index.js +19 -0
  25. package/commonjs/src/http/http.compression.js +27 -0
  26. package/commonjs/src/http/http.extensions.js +269 -0
  27. package/commonjs/src/http/http.options.js +26 -0
  28. package/commonjs/src/http/http.options.store.js +20 -0
  29. package/commonjs/src/http/http.security.js +27 -0
  30. package/commonjs/src/http/http.static.js +248 -0
  31. package/commonjs/src/http/index.js +22 -0
  32. package/commonjs/src/index.js +27 -0
  33. package/commonjs/src/openapi/index.js +45 -0
  34. package/commonjs/src/openapi/openapi.generator.js +103 -0
  35. package/commonjs/src/openapi/openapi.types.js +4 -0
  36. package/commonjs/src/openapi/request-schema.js +187 -0
  37. package/commonjs/src/openapi/request-validator.js +85 -0
  38. package/commonjs/src/providers/fastify.provider.js +391 -0
  39. package/commonjs/src/providers/index.js +18 -0
  40. package/commonjs/src/routing/anvil.types.js +99 -0
  41. package/commonjs/src/routing/decorators/argument.decorator.js +49 -0
  42. package/commonjs/src/routing/decorators/auth.decorator.js +70 -0
  43. package/commonjs/src/routing/decorators/controller.decorator.js +16 -0
  44. package/commonjs/src/routing/decorators/field.decorator.js +25 -0
  45. package/commonjs/src/routing/decorators/index.js +24 -0
  46. package/commonjs/src/routing/decorators/request.decorator.js +30 -0
  47. package/commonjs/src/routing/decorators/response.decorator.js +34 -0
  48. package/commonjs/src/routing/decorators/route.decorator.js +67 -0
  49. package/commonjs/src/routing/filters/exception-filter-host.js +145 -0
  50. package/commonjs/src/routing/filters/exception-filter.js +28 -0
  51. package/commonjs/src/routing/index.js +25 -0
  52. package/commonjs/src/routing/middleware/http-access-log.middleware.js +47 -0
  53. package/commonjs/src/routing/middleware/middleware.js +20 -0
  54. package/commonjs/src/routing/middleware/request-id.js +30 -0
  55. package/commonjs/src/routing/path.util.js +51 -0
  56. package/commonjs/src/routing/pipes/index.js +18 -0
  57. package/commonjs/src/routing/pipes/pipes.js +145 -0
  58. package/commonjs/src/routing/services/index.js +18 -0
  59. package/commonjs/src/routing/services/route-argument-resolver.js +77 -0
  60. package/commonjs/src/routing/services/route-registry.js +194 -0
  61. package/commonjs/src/routing/services/router.js +158 -0
  62. package/commonjs/src/sse/index.js +26 -0
  63. package/commonjs/src/sse/sse.decorator.js +42 -0
  64. package/commonjs/src/sse/sse.encoder.js +73 -0
  65. package/commonjs/src/sse/sse.types.js +36 -0
  66. package/commonjs/src/sse/sse.wire.js +40 -0
  67. package/commonjs/src/testing/boot-module.js +38 -0
  68. package/commonjs/src/testing/index.js +20 -0
  69. package/commonjs/src/testing/sse-reader.js +98 -0
  70. package/es6/src/app/app.js +148 -0
  71. package/es6/src/app/index.js +2 -0
  72. package/es6/src/app/module.js +7 -0
  73. package/es6/src/auth/ability.js +45 -0
  74. package/es6/src/auth/auth.config.js +19 -0
  75. package/es6/src/auth/auth.extensions.js +83 -0
  76. package/es6/src/auth/auth.types.js +35 -0
  77. package/es6/src/auth/casl-authorization.service.js +40 -0
  78. package/es6/src/auth/index.js +6 -0
  79. package/es6/src/auth/principal-provider.js +14 -0
  80. package/es6/src/auth/safe-outbound-url.js +99 -0
  81. package/es6/src/auth/token-service.js +141 -0
  82. package/es6/src/builder/builder.js +85 -0
  83. package/es6/src/builder/index.js +1 -0
  84. package/es6/src/const.js +33 -0
  85. package/es6/src/health/health.controller.js +65 -0
  86. package/es6/src/health/health.extensions.js +40 -0
  87. package/es6/src/health/index.js +4 -0
  88. package/es6/src/health/readiness.service.js +57 -0
  89. package/es6/src/health/readiness.types.js +4 -0
  90. package/es6/src/hosting/hosted-service-host.js +81 -0
  91. package/es6/src/hosting/hosted-service.js +8 -0
  92. package/es6/src/hosting/index.js +2 -0
  93. package/es6/src/http/http.compression.js +9 -0
  94. package/es6/src/http/http.extensions.js +259 -0
  95. package/es6/src/http/http.options.js +16 -0
  96. package/es6/src/http/http.options.store.js +13 -0
  97. package/es6/src/http/http.security.js +9 -0
  98. package/es6/src/http/http.static.js +224 -0
  99. package/es6/src/http/index.js +5 -0
  100. package/es6/src/index.js +10 -0
  101. package/es6/src/openapi/index.js +3 -0
  102. package/es6/src/openapi/openapi.generator.js +82 -0
  103. package/es6/src/openapi/openapi.types.js +1 -0
  104. package/es6/src/openapi/request-schema.js +160 -0
  105. package/es6/src/openapi/request-validator.js +71 -0
  106. package/es6/src/providers/fastify.provider.js +376 -0
  107. package/es6/src/providers/index.js +1 -0
  108. package/es6/src/routing/anvil.types.js +57 -0
  109. package/es6/src/routing/decorators/argument.decorator.js +28 -0
  110. package/es6/src/routing/decorators/auth.decorator.js +58 -0
  111. package/es6/src/routing/decorators/controller.decorator.js +6 -0
  112. package/es6/src/routing/decorators/field.decorator.js +15 -0
  113. package/es6/src/routing/decorators/index.js +7 -0
  114. package/es6/src/routing/decorators/request.decorator.js +24 -0
  115. package/es6/src/routing/decorators/response.decorator.js +16 -0
  116. package/es6/src/routing/decorators/route.decorator.js +37 -0
  117. package/es6/src/routing/filters/exception-filter-host.js +127 -0
  118. package/es6/src/routing/filters/exception-filter.js +10 -0
  119. package/es6/src/routing/index.js +8 -0
  120. package/es6/src/routing/middleware/http-access-log.middleware.js +37 -0
  121. package/es6/src/routing/middleware/middleware.js +10 -0
  122. package/es6/src/routing/middleware/request-id.js +14 -0
  123. package/es6/src/routing/path.util.js +33 -0
  124. package/es6/src/routing/pipes/index.js +1 -0
  125. package/es6/src/routing/pipes/pipes.js +118 -0
  126. package/es6/src/routing/services/index.js +1 -0
  127. package/es6/src/routing/services/route-argument-resolver.js +67 -0
  128. package/es6/src/routing/services/route-registry.js +184 -0
  129. package/es6/src/routing/services/router.js +148 -0
  130. package/es6/src/sse/index.js +2 -0
  131. package/es6/src/sse/sse.decorator.js +31 -0
  132. package/es6/src/sse/sse.encoder.js +67 -0
  133. package/es6/src/sse/sse.types.js +22 -0
  134. package/es6/src/sse/sse.wire.js +30 -0
  135. package/es6/src/testing/boot-module.js +35 -0
  136. package/es6/src/testing/index.js +2 -0
  137. package/es6/src/testing/sse-reader.js +91 -0
  138. package/package.json +64 -0
  139. package/types/src/app/app.d.ts +41 -0
  140. package/types/src/app/index.d.ts +2 -0
  141. package/types/src/app/module.d.ts +15 -0
  142. package/types/src/auth/ability.d.ts +19 -0
  143. package/types/src/auth/auth.config.d.ts +28 -0
  144. package/types/src/auth/auth.extensions.d.ts +53 -0
  145. package/types/src/auth/auth.types.d.ts +73 -0
  146. package/types/src/auth/casl-authorization.service.d.ts +28 -0
  147. package/types/src/auth/index.d.ts +6 -0
  148. package/types/src/auth/principal-provider.d.ts +20 -0
  149. package/types/src/auth/safe-outbound-url.d.ts +9 -0
  150. package/types/src/auth/token-service.d.ts +31 -0
  151. package/types/src/builder/builder.d.ts +36 -0
  152. package/types/src/builder/index.d.ts +1 -0
  153. package/types/src/const.d.ts +32 -0
  154. package/types/src/health/health.controller.d.ts +16 -0
  155. package/types/src/health/health.extensions.d.ts +31 -0
  156. package/types/src/health/index.d.ts +4 -0
  157. package/types/src/health/readiness.service.d.ts +9 -0
  158. package/types/src/health/readiness.types.d.ts +14 -0
  159. package/types/src/hosting/hosted-service-host.d.ts +37 -0
  160. package/types/src/hosting/hosted-service.d.ts +31 -0
  161. package/types/src/hosting/index.d.ts +2 -0
  162. package/types/src/http/http.compression.d.ts +32 -0
  163. package/types/src/http/http.extensions.d.ts +79 -0
  164. package/types/src/http/http.options.d.ts +39 -0
  165. package/types/src/http/http.options.store.d.ts +7 -0
  166. package/types/src/http/http.security.d.ts +20 -0
  167. package/types/src/http/http.static.d.ts +160 -0
  168. package/types/src/http/index.d.ts +5 -0
  169. package/types/src/index.d.ts +10 -0
  170. package/types/src/openapi/index.d.ts +6 -0
  171. package/types/src/openapi/openapi.generator.d.ts +6 -0
  172. package/types/src/openapi/openapi.types.d.ts +58 -0
  173. package/types/src/openapi/request-schema.d.ts +21 -0
  174. package/types/src/openapi/request-validator.d.ts +9 -0
  175. package/types/src/providers/fastify.provider.d.ts +56 -0
  176. package/types/src/providers/index.d.ts +1 -0
  177. package/types/src/routing/anvil.types.d.ts +119 -0
  178. package/types/src/routing/decorators/argument.decorator.d.ts +11 -0
  179. package/types/src/routing/decorators/auth.decorator.d.ts +30 -0
  180. package/types/src/routing/decorators/controller.decorator.d.ts +3 -0
  181. package/types/src/routing/decorators/field.decorator.d.ts +2 -0
  182. package/types/src/routing/decorators/index.d.ts +7 -0
  183. package/types/src/routing/decorators/request.decorator.d.ts +8 -0
  184. package/types/src/routing/decorators/response.decorator.d.ts +4 -0
  185. package/types/src/routing/decorators/route.decorator.d.ts +7 -0
  186. package/types/src/routing/filters/exception-filter-host.d.ts +19 -0
  187. package/types/src/routing/filters/exception-filter.d.ts +16 -0
  188. package/types/src/routing/index.d.ts +8 -0
  189. package/types/src/routing/middleware/http-access-log.middleware.d.ts +8 -0
  190. package/types/src/routing/middleware/middleware.d.ts +10 -0
  191. package/types/src/routing/middleware/request-id.d.ts +6 -0
  192. package/types/src/routing/path.util.d.ts +9 -0
  193. package/types/src/routing/pipes/index.d.ts +1 -0
  194. package/types/src/routing/pipes/pipes.d.ts +21 -0
  195. package/types/src/routing/services/index.d.ts +1 -0
  196. package/types/src/routing/services/route-argument-resolver.d.ts +10 -0
  197. package/types/src/routing/services/route-registry.d.ts +20 -0
  198. package/types/src/routing/services/router.d.ts +22 -0
  199. package/types/src/sse/index.d.ts +3 -0
  200. package/types/src/sse/sse.decorator.d.ts +17 -0
  201. package/types/src/sse/sse.encoder.d.ts +16 -0
  202. package/types/src/sse/sse.types.d.ts +37 -0
  203. package/types/src/sse/sse.wire.d.ts +9 -0
  204. package/types/src/testing/boot-module.d.ts +34 -0
  205. package/types/src/testing/index.d.ts +4 -0
  206. package/types/src/testing/sse-reader.d.ts +29 -0
package/README.md ADDED
@@ -0,0 +1,246 @@
1
+ # @decohere/http
2
+
3
+ HTTP/API platform for Decohere: decorator-based controllers, auth, OpenAPI, health, and a Fastify provider at the edge. Architecture and the provider boundary: [docs/architecture](../../docs/architecture/).
4
+
5
+ Controllers and services depend on the contracts exported from `@decohere/http`. Fastify stays inside `FastifyProvider`.
6
+
7
+ ## Serving a built UI
8
+
9
+ A module can serve its UI's build from its own API: one image, one origin, no CORS.
10
+
11
+ ```ts
12
+ Builder.create()
13
+ .withHttp(FastifyProvider)
14
+ .withControllers(PortfolioController)
15
+ .withStatic("/ui/", "ui"); // https://host/ui/ serves ./ui/index.html
16
+ ```
17
+
18
+ `directory` is resolved from the folder the app runs in (the process working directory), like `./config`. For a deployable that is `.package/`, where [`forge package`](../forge/README.md#forge-package-one-folder-a-plain-node-can-run) puts the UI (the `ui` declaration there). Call `withStatic` again to serve more than one UI.
19
+
20
+ ```ts
21
+ .withStatic("/ui/", "ui", {
22
+ spaFallback: true, // default
23
+ exclude: ["/docs/**"], // paths this mount never answers; default none
24
+ index: "index.html", // default
25
+ noCache: ["index.html", "remoteEntry.js"], // default
26
+ immutable: /-[A-Za-z0-9_]{8,}\.\w+$/, // default is a stricter hash pattern; false = never
27
+ cacheControl: "public, max-age=0, must-revalidate", // default, for every other file
28
+ onMissingDirectory: "fail", // default in production; "warn" in development
29
+ })
30
+ ```
31
+
32
+ ### Rules
33
+
34
+ - **Public.** Static files are served before the HTTP pipeline: `@Authorize`, tokens, validation and middleware never apply to the prefix, with or without `withAuth()`. An API route next to it still answers `401`. They are not listed in OpenAPI.
35
+ - **Outside the rate limit.** A page load fetches dozens of files, so the API's limit would block ordinary use. Anything that is not served by the static plugin (including a `POST` to the prefix) is limited as usual. Helmet and CORS still apply.
36
+ - **Caching.** `index.html` and `remoteEntry.js` (the `noCache` list; a module-federation host loads the remote entry by a fixed name): `Cache-Control: no-cache`. A file whose name carries a content hash, such as `assets/index-DiwrgTda.js` or `style.3f9a8c1b.css` (8 or more letters, digits or `_` after a `-` or `.`, with a digit or a lower-then-upper pair): `public, max-age=31536000, immutable`. Everything else: `public, max-age=0, must-revalidate` with `ETag` and `Last-Modified`, so it is revalidated and answered `304`. `HEAD` and conditional requests work.
37
+ - **Client routes.** An unknown path under the prefix (`/ui/portfolio/42`) serves `index.html`, so a reload on a client route works. It decides by what is asking, and serves the app only when **all** of these hold: the method is `GET` or `HEAD`; the path has no extension and no dotfile (`/ui/assets/gone.js` is a real `404`); the `Accept` header includes `text/html` (what a browser sends when it navigates; `application/json` or `*/*` does not count); and the path is not under one of the app's own routes (below). Anything else gets the HTTP package's normal JSON `404`. `/ui` redirects to `/ui/`.
38
+ - **Safe paths.** `..` in any encoding cannot leave the directory, dotfiles answer `404`, and a directory is never listed.
39
+ - **Safe paths.** `..` in any encoding cannot leave the directory, dotfiles answer `404`, and a directory is never listed.
40
+ - **Missing directory.** In production (`NODE_ENV` not `development` or `dev`) the app **fails at start** with the path that is missing. In development it logs a warning and boots, so the API runs before the UI is built; the prefix answers `404` until you restart with the UI built. Set `onMissingDirectory` to choose.
41
+ - **Prefix.** The global prefix does not apply to it. Two mounts work, and the more specific prefix wins (`/admin/` over `/`).
42
+
43
+ ### Serving the UI from the root
44
+
45
+ ```ts
46
+ Builder.create()
47
+ .withHttp(FastifyProvider)
48
+ .withControllers(PortfolioController) // /lp/portfolio, /module/manifest, ...
49
+ .withHealth()
50
+ .withStatic("/", "ui", { exclude: ["/docs/**"] });
51
+ ```
52
+
53
+ `GET /` serves `index.html`, and so do `GET /portfolio/42` or any other client route a browser opens. The API keeps working because Fastify matches a specific route before the static wildcard; what needed care was an unknown path, where the app is the not-found handler. A request the HTTP package does not hand to the app gets the same JSON `404` as an app without a UI. A mistyped `GET /lp/portfolo` from an API client asks for JSON (or `*/*`), so it never gets HTML with status `200`.
54
+
55
+ **Under one of the app's own routes.** Without a global prefix, a path is under a registered route when its first segment is the first segment of any controller route, of `/health` and of the OpenAPI document. With a route `/lp/portfolio`, `/lp/portfolo` and `/lp/a/b` are API paths and answer the JSON `404` even to a browser; `/lpx` and `/portfolio` are not, and get the app. A route whose first segment is a parameter (`/:id`) says nothing and is skipped. So a client route cannot start with the same name as an API route.
56
+
57
+ **With a global prefix** (`withGlobalPrefix("/api")`) the HTTP package knows where the API lives, so it stops guessing: a path is the API's when it is under the prefix (`/api`, `/api/anything`, not `/apiary`) or at the OpenAPI path, and everything else is the UI's. `/lp/positions` gets the app even if a controller has `/lp/portfolio`, as long as that controller is under the prefix. Routes left out of the prefix (the probes, a controller with `globalPrefix: false`) are the API's too, with every path below them: `/health/x` is a JSON `404`, `/healthy` gets the app. Either way it only limits the fallback: a real file is still served, and a registered route is never shadowed.
58
+
59
+ **`exclude`.** Patterns for paths the mount never answers, neither a file nor the fallback (the request gets the JSON `404`), for anything the defaults get wrong. They are globs ([picomatch](https://github.com/micromatch/picomatch), `dot` on) matched against the full request path after decoding it and collapsing repeated slashes, not relative to the prefix: `*` is any characters within one segment, `**` crosses segments, `?` is one character. `"/docs/**"` covers `/docs`, `/docs/` and everything below.
60
+
61
+ Why this shape, and what is left (running the UI's own build is [#381](https://github.com/typesmiths/decohere/issues/381)): [ADR 0007](../../docs/adr/0007-serving-a-built-ui-from-http.md).
62
+
63
+ ## Global prefix
64
+
65
+ ```ts
66
+ Builder.create()
67
+ .withHttp(FastifyProvider)
68
+ .withControllers(PortfolioController) // /lp/portfolio -> /api/lp/portfolio
69
+ .withHealth() // /health and /ready stay at the root
70
+ .withGlobalPrefix("/api")
71
+ .withStatic("/", "ui");
72
+ ```
73
+
74
+ `withGlobalPrefix(prefix)` puts every **controller route** under the prefix. It does not cover:
75
+
76
+ - **Static mounts** (`withStatic`): they have their own prefix.
77
+ - **The OpenAPI document**: it keeps its own absolute path (`/openapi.json` by default).
78
+ - **A controller that opts out** with `@Controller({ prefix: "/webhooks", globalPrefix: false })`, for paths something outside the app calls at a fixed place: webhooks, metrics.
79
+ - **The `/health` and `/ready` probes.** The health controller opts out, because Cloud Run, Kubernetes and most load balancers probe those paths at the root. `withHealth({ underGlobalPrefix: true })` puts them under the prefix instead.
80
+
81
+ What stays out is decided by the controller, never by matching a path, so a controller of yours that declares `/health` is prefixed like any other.
82
+
83
+ The prefix is also what the static fallback treats as the API, see [Serving the UI from the root](#serving-the-ui-from-the-root).
84
+
85
+ ## Compression
86
+
87
+ Responses are compressed with `br` or `gzip`, whichever the client accepts (`Accept-Encoding`), in every environment. Nothing to switch on: the HTTP package registers [`@fastify/compress`](https://github.com/fastify/fastify-compress) with best-practice defaults, and you override them if you need to. The plugin owns the policy; the HTTP package does not re-implement it.
88
+
89
+ Defaults: on, a 1024-byte threshold, `encodings: ["br", "gzip"]` (no `deflate`), and the plugin's own list of compressible types (text, JSON, XML, `octet-stream`, and anything the MIME database marks compressible). Images, video, woff2 fonts, zip and PDF are not compressed again, and `text/event-stream` is not compressed. A client that sends no `Accept-Encoding` gets the plain body.
90
+
91
+ ```ts
92
+ .withCompression(false) // off, for example behind a proxy that compresses
93
+ .withCompression({
94
+ threshold: 2048, // bytes, default 1024
95
+ encodings: ["br", "gzip"], // in order of preference; "deflate" is allowed
96
+ types: /^application\/vnd\.acme/, // see below
97
+ providerOptions: { // Fastify-specific: any @fastify/compress option, merged last
98
+ brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 6 } },
99
+ },
100
+ })
101
+ ```
102
+
103
+ What to know about the plugin's behaviour:
104
+
105
+ - **`types` is for adding types.** It is passed as `customTypes`: it replaces the plugin's default pattern, but anything the MIME database marks compressible is still compressed. Do not match `text/event-stream` unless you want streams buffered.
106
+ - **The threshold does not apply to streams.** Files served by `withStatic` are streams, so even a small text file is compressed on the fly. Their `Cache-Control`, `ETag` and `304` behave as before.
107
+ - **`application/octet-stream` is compressed by default.** If you serve already-compressed bytes under that type, set the `Content-Type` of the real format or set your own `Content-Encoding`.
108
+ - **Server-sent events are not compressed** by the plugin's default pattern, because compression buffers output and events would arrive late. Keep it that way.
109
+ - **Unread streamed bodies hold up stopping.** A compressed file stays open until the client has read it, so `app.dispose()` waits for a client that fetched a static file and never read the body. In tests, read or cancel the body (or send `Accept-Encoding: identity`).
110
+ - **Already encoded.** A response that sets its own `Content-Encoding` is passed through.
111
+ - **`Vary: Accept-Encoding`** is added when a response is compressed.
112
+ - **`HEAD`** is answered uncompressed: Fastify builds a `HEAD` reply before the plugin runs, so it has the plain `Content-Length` and no `Vary`. It has no body either way.
113
+ - **A `304`** for a file can carry the `Content-Encoding` header of the `200` it stands for. It has no body.
114
+ - **Helmet, CORS and the rate limit** are unaffected, and an error reply (a `401`, a `400` with a large body) is compressed like any other.
115
+ - **BREACH.** Compressing a response served over HTTPS that contains both a secret and input an attacker controls can leak the secret to someone who can watch response sizes. It mostly concerns HTML that reflects input next to a CSRF token. A JSON API that authenticates with a bearer token in a header is low risk, because the token is not in the body. If you render such pages, mask per-response secrets, do not reflect input beside them, or turn compression off for them.
116
+ - **Pre-compressed files.** Serving `.br`/`.gz` files built ahead of time is a possible later step ([#642](https://github.com/typesmiths/decohere/issues/642)); today static files are compressed on the fly.
117
+ - **Testing.** Node's `fetch` negotiates and decodes compression itself. To see what is on the wire, use `node:http` and read the body undecoded.
118
+
119
+ Why this shape: [ADR 0008](../../docs/adr/0008-response-compression-in-http.md).
120
+
121
+ ## Server-sent events
122
+
123
+ A controller method can push events to the browser. Mark it `@Sse()`, make it an `async *` method (any `AsyncIterable` works), and yield messages:
124
+
125
+ ```ts
126
+ import {
127
+ Authorize,
128
+ Controller,
129
+ Get,
130
+ LastEventId,
131
+ Signal,
132
+ Sse,
133
+ type SseStream,
134
+ } from "@decohere/http";
135
+ import { setInterval } from "node:timers/promises";
136
+
137
+ @Controller({ prefix: "/lp" })
138
+ class PortfolioController {
139
+ public constructor(private readonly book: PositionBook) {}
140
+
141
+ @Authorize("read", "Portfolio")
142
+ @Sse({ heartbeatMs: 15_000 })
143
+ @Get("/portfolio/stream")
144
+ public async *stream(
145
+ @Signal() signal: AbortSignal,
146
+ @LastEventId() lastEventId?: string,
147
+ ): SseStream {
148
+ // A browser that reconnects sends the id of the last event it saw. Start after it.
149
+ yield* this.book.replaySince(lastEventId);
150
+
151
+ for await (const _ of setInterval(1000, undefined, { signal })) {
152
+ yield { id: String(this.book.version), event: "pnl", data: this.book.snapshot() };
153
+ }
154
+ }
155
+ }
156
+ ```
157
+
158
+ Browser side: `new EventSource("/lp/portfolio/stream")`. It reconnects by itself and sends `Last-Event-ID`.
159
+
160
+ ### What the HTTP package does
161
+
162
+ - **Auth first.** The route goes through the same pipeline as any other: `@Authorize`, `@AllowAnonymous`, token checks, request validation and middleware all run before the handler. An unauthorised caller gets a normal `401` or `403` JSON response and no event is ever sent.
163
+ - **Format.** Responds `200 text/event-stream`. A yielded string is a `data:` line. A yielded `{ data, event?, id?, retry? }` sets those fields. Non-string `data` is written as JSON, and line breaks in `id` and `event` are removed so a value cannot inject another field.
164
+ - **Heartbeats.** After `heartbeatMs` (default 15 s) without an event, the HTTP package writes a `: heartbeat` comment so proxies do not close the idle connection. `heartbeatMs: 0` turns it off.
165
+ - **Cancellation.** `@Signal()` gives the handler an `AbortSignal` that aborts when the client disconnects or the app stops. Hand it to whatever the producer waits on (a timer, a subscription, a query). The generator is then stopped and its `finally` blocks run. If the producer ignores the signal, the HTTP package still ends the response, but the producer keeps running until its current `await` finishes.
166
+ - **Request scope.** Request-scoped services the controller received stay alive until the stream ends, then are disposed.
167
+ - **App stop.** `app.dispose()` (also what Flux calls on a reboot, and what a SIGTERM runs) aborts every open stream and waits for them to end before closing.
168
+ - **OpenAPI.** The `200` response is documented as `text/event-stream`, and a `@LastEventId()` argument shows up as the `Last-Event-ID` header parameter.
169
+
170
+ Why this shape, and what is left for later: [ADR 0005](../../docs/adr/0005-server-sent-events-in-http.md).
171
+
172
+ ### Testing a stream
173
+
174
+ `@decohere/http/testing` boots your controllers as a real app on a free port and reads events from it:
175
+
176
+ ```ts
177
+ import { bootModule } from "@decohere/http/testing";
178
+
179
+ it("streams the first three prices", async () => {
180
+ await using api = await bootModule({
181
+ controllers: [PriceController],
182
+ services: (c) => c.registerSingleton(PriceFeed),
183
+ configure: (builder) => builder.withAuth(),
184
+ });
185
+
186
+ const { status, events } = await api.readEvents("/prices/stream", {
187
+ count: 3,
188
+ headers: { authorization: `Bearer ${token}` },
189
+ });
190
+
191
+ expect(status).toBe(200);
192
+ expect(events.map((e) => e.data)).toEqual(["1", "2", "3"]);
193
+ });
194
+ ```
195
+
196
+ `readEvents` stops and closes the connection after `count` events (default 1). `count: Infinity` reads until the server ends the stream, which is how you test that stopping the app closes it. A non-200 answer returns at once with `status` set and no events, so `401` and `403` are easy to assert. `comments` holds the `:` lines, so heartbeats are visible too. `await using` (or `api.stop()`) stops the app.
197
+
198
+ ## Hosted services
199
+
200
+ Work that runs for the life of the app, outside any request: a feed that refreshes on a timer, a poller, a queue consumer. Implement `HostedService` and register it:
201
+
202
+ ```ts
203
+ @injectable()
204
+ export class PositionFeed implements HostedService {
205
+ private warm = false;
206
+ private loading?: Promise<void>;
207
+
208
+ public constructor(@inject(PositionBook) private readonly book: PositionBook) {}
209
+
210
+ public start(signal: AbortSignal) {
211
+ // Don't wait here: a first load that takes minutes would keep the HTTP server dark.
212
+ this.loading = this.book.loadAll(signal).then(() => {
213
+ this.warm = true;
214
+ });
215
+ const timer = setInterval(() => void this.book.refreshOpen(signal), 60_000);
216
+ signal.addEventListener("abort", () => clearInterval(timer));
217
+ }
218
+
219
+ public async stop() {
220
+ await this.loading; // the signal already told it to stop; wait for it to wind down
221
+ }
222
+
223
+ public async check() {
224
+ if (!this.warm) throw new Error("positions still loading"); // GET /ready answers 503
225
+ }
226
+ }
227
+
228
+ Builder.create()
229
+ .withHttp(FastifyProvider)
230
+ .withHealth()
231
+ .withTypeOrm(lp)
232
+ .withHostedService(PositionFeed);
233
+ ```
234
+
235
+ **Starting.** `app.start()` starts hosted services in registration order, and awaits each `start(signal)`, before the HTTP server listens. So a request never reaches a service that hasn't started, and a `start()` that throws fails `app.start()` (the ones already started are stopped). Keep `start()` short: set up what must be ready before traffic, then run long work in the background and return.
236
+
237
+ **Warming up.** A service that is started but can't serve yet implements `check()`: it throws until the service is ready, and `GET /ready` (`withHealth()`) answers 503 until then. `/health` is unaffected.
238
+
239
+ **Stopping.** `app.dispose()` runs on `SIGTERM`/`SIGINT` and on every Flux reboot. It:
240
+
241
+ 1. aborts `signal`, at once, also while a `start()` is still running. Clear timers and cancel work on it.
242
+ 2. lets in-flight HTTP requests finish.
243
+ 3. awaits each `stop()` in reverse order. If a `start()` was still running, it waits for it first. Every `stop()` runs even if one throws.
244
+ 4. disposes the container, which closes what the services used (DataSources from `withTypeOrm`, anything with `Symbol.asyncDispose`).
245
+
246
+ `stop()` is optional: a service with nothing to wait for can rely on the signal, or implement `Symbol.asyncDispose`, which step 4 calls. A hosted service is a singleton, so other services and controllers can inject it by its class. Why this shape: [ADR 0010](../../docs/adr/0010-hosted-services.md).
@@ -0,0 +1,166 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ function _export(target, all) {
6
+ for(var name in all)Object.defineProperty(target, name, {
7
+ enumerable: true,
8
+ get: Object.getOwnPropertyDescriptor(all, name).get
9
+ });
10
+ }
11
+ _export(exports, {
12
+ get ApiConfiguration () {
13
+ return ApiConfiguration;
14
+ },
15
+ get App () {
16
+ return App;
17
+ }
18
+ });
19
+ const _crucible = require("@decohere/crucible");
20
+ const _decorators = require("@decohere/crucible/decorators");
21
+ const _configuration = require("@decohere/core/configuration");
22
+ const _scribe = require("@decohere/scribe");
23
+ const _const = require("../const.js");
24
+ function _ts_decorate(decorators, target, key, desc) {
25
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
26
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
27
+ else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
28
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
29
+ }
30
+ function _ts_metadata(k, v) {
31
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
32
+ }
33
+ function _ts_param(paramIndex, decorator) {
34
+ return function(target, key) {
35
+ decorator(target, key, paramIndex);
36
+ };
37
+ }
38
+ const DEFAULT_PORT = 3000;
39
+ const DEFAULT_SHUTDOWN_SIGNALS = [
40
+ "SIGTERM",
41
+ "SIGINT"
42
+ ];
43
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
44
+ class ApiConfiguration {
45
+ port;
46
+ }
47
+ class App {
48
+ container;
49
+ configurationService;
50
+ httpProvider;
51
+ hostedServices;
52
+ _addresses = Array.of();
53
+ logger = _scribe.scribe.logger(App.name);
54
+ disposing;
55
+ disposingContainer = false;
56
+ signalHandler;
57
+ shutdownSignals = [];
58
+ constructor(container, configurationService, httpProvider, hostedServices){
59
+ this.container = container;
60
+ this.configurationService = configurationService;
61
+ this.httpProvider = httpProvider;
62
+ this.hostedServices = hostedServices;
63
+ }
64
+ get addresses() {
65
+ return this._addresses;
66
+ }
67
+ async start(options) {
68
+ this.logger.info("Starting app");
69
+ // Before HTTP, so a request never reaches a service that has not started.
70
+ await this.hostedServices?.start(this.container);
71
+ // The app was stopped while a hosted service was still starting.
72
+ if (this.disposing != null) return;
73
+ const hasHostedServices = this.hostedServices != null && !this.hostedServices.isEmpty;
74
+ if (!this.httpProvider) {
75
+ if (hasHostedServices) this.stopOnSignals(options);
76
+ return;
77
+ }
78
+ const port = await this.getPort(options);
79
+ this.logger.info(`Starting http on port ${port}`);
80
+ const startResponse = await this.httpProvider?.start({
81
+ port,
82
+ ...options?.host != null ? {
83
+ host: options.host
84
+ } : {}
85
+ });
86
+ this._addresses = [
87
+ ...startResponse.addresses
88
+ ];
89
+ this.logger.info("Listening on:", this._addresses);
90
+ this.stopOnSignals(options);
91
+ return startResponse;
92
+ }
93
+ [Symbol.asyncDispose]() {
94
+ // The container holds this App too; when stop() disposes the container it lands
95
+ // here, and awaiting our own shutdown would deadlock.
96
+ if (this.disposingContainer) return Promise.resolve();
97
+ return this.dispose();
98
+ }
99
+ /** Stop the app. Calling it again (or from a signal) returns the same shutdown. */ dispose() {
100
+ this.disposing ??= this.stop();
101
+ return this.disposing;
102
+ }
103
+ async stop() {
104
+ // Removed first, so a second Ctrl+C / SIGTERM gets Node's default: exit now.
105
+ this.removeSignalHandlers();
106
+ this.logger.info("Stopping");
107
+ // Hosted services hear the signal at once, so background work winds down while HTTP drains.
108
+ this.hostedServices?.abort();
109
+ try {
110
+ // HTTP first: in-flight requests still need the services (DB pools etc.) that
111
+ // disposing the container would close.
112
+ await this.httpProvider?.stop();
113
+ // Hosted services next, while what they use is still open.
114
+ await this.hostedServices?.stop();
115
+ } finally{
116
+ this.disposingContainer = true;
117
+ await this.container[Symbol.asyncDispose]();
118
+ await _scribe.scribe.stop();
119
+ }
120
+ }
121
+ stopOnSignals(options) {
122
+ const signals = options?.shutdownSignals ?? DEFAULT_SHUTDOWN_SIGNALS;
123
+ const timeoutMs = options?.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
124
+ if (signals === false || signals.length === 0) return;
125
+ this.signalHandler = (signal)=>{
126
+ this.logger.info(`Received ${signal}; finishing in-flight requests`);
127
+ // console.error, not the logger: scribe may be the thing that's stuck.
128
+ const timer = setTimeout(()=>{
129
+ console.error(`Shutdown did not finish within ${timeoutMs}ms; exiting.`);
130
+ process.exit(1);
131
+ }, timeoutMs);
132
+ timer.unref();
133
+ this.dispose().then(()=>clearTimeout(timer), (error)=>{
134
+ clearTimeout(timer);
135
+ console.error("Shutdown failed:", error);
136
+ process.exit(1);
137
+ });
138
+ };
139
+ this.shutdownSignals = signals;
140
+ for (const signal of signals)process.on(signal, this.signalHandler);
141
+ }
142
+ removeSignalHandlers() {
143
+ if (!this.signalHandler) return;
144
+ for (const signal of this.shutdownSignals)process.off(signal, this.signalHandler);
145
+ }
146
+ async getPort(options) {
147
+ const port = options?.port ?? (await this.configurationService?.get(ApiConfiguration))?.port ?? DEFAULT_PORT;
148
+ return port;
149
+ }
150
+ }
151
+ App = _ts_decorate([
152
+ _ts_param(0, (0, _decorators.inject)(_crucible.LifetimeScopeToken)),
153
+ _ts_param(1, (0, _decorators.inject)(_configuration.ConfigurationServiceToken)),
154
+ _ts_param(1, (0, _decorators.optional)()),
155
+ _ts_param(2, (0, _decorators.inject)(_const.HttpProviderToken)),
156
+ _ts_param(2, (0, _decorators.optional)()),
157
+ _ts_param(3, (0, _decorators.inject)(_const.HostedServiceHostToken)),
158
+ _ts_param(3, (0, _decorators.optional)()),
159
+ _ts_metadata("design:type", Function),
160
+ _ts_metadata("design:paramtypes", [
161
+ typeof LifetimeScope === "undefined" ? Object : LifetimeScope,
162
+ typeof IConfigurationService === "undefined" ? Object : IConfigurationService,
163
+ typeof HttpProvider === "undefined" ? Object : HttpProvider,
164
+ typeof HostedServiceHost === "undefined" ? Object : HostedServiceHost
165
+ ])
166
+ ], App);
@@ -0,0 +1,19 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ _export_star(require("./app.js"), exports);
6
+ _export_star(require("./module.js"), exports);
7
+ function _export_star(from, to) {
8
+ Object.keys(from).forEach(function(k) {
9
+ if (k !== "default" && !Object.prototype.hasOwnProperty.call(to, k)) {
10
+ Object.defineProperty(to, k, {
11
+ enumerable: true,
12
+ get: function() {
13
+ return from[k];
14
+ }
15
+ });
16
+ }
17
+ });
18
+ return from;
19
+ }
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "Module", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return Module;
9
+ }
10
+ });
11
+ class Module {
12
+ constructor(data){
13
+ Object.assign(this, data);
14
+ }
15
+ entities;
16
+ repositories;
17
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ function _export(target, all) {
6
+ for(var name in all)Object.defineProperty(target, name, {
7
+ enumerable: true,
8
+ get: Object.getOwnPropertyDescriptor(all, name).get
9
+ });
10
+ }
11
+ _export(exports, {
12
+ get ADMIN_ROLE () {
13
+ return ADMIN_ROLE;
14
+ },
15
+ get createPrincipalFromPayload () {
16
+ return createPrincipalFromPayload;
17
+ },
18
+ get defineAbilitiesFor () {
19
+ return defineAbilitiesFor;
20
+ }
21
+ });
22
+ const _ability = require("@casl/ability");
23
+ const ADMIN_ROLE = "admin";
24
+ function defineAbilitiesFor(principal) {
25
+ const { can, build } = new _ability.AbilityBuilder(_ability.createMongoAbility);
26
+ if (principal.roles?.includes(ADMIN_ROLE)) {
27
+ can("manage", "all");
28
+ return build();
29
+ }
30
+ for (const scope of principal.scopes ?? []){
31
+ const [action, subject] = scope.split(":");
32
+ if (action && subject) can(action, subject);
33
+ }
34
+ return build();
35
+ }
36
+ function createPrincipalFromPayload(payload) {
37
+ const sub = typeof payload.sub === "string" ? payload.sub : "";
38
+ const fromScopeClaim = typeof payload.scope === "string" ? payload.scope.split(" ").filter(Boolean) : Array.isArray(payload.scopes) ? payload.scopes.filter((s)=>typeof s === "string") : [];
39
+ const fromPermissions = Array.isArray(payload.permissions) ? payload.permissions.filter((s)=>typeof s === "string") : [];
40
+ const merged = [
41
+ ...new Set([
42
+ ...fromScopeClaim,
43
+ ...fromPermissions
44
+ ])
45
+ ];
46
+ const scopes = merged.length > 0 ? merged : undefined;
47
+ const roles = Array.isArray(payload.roles) ? payload.roles.filter((r)=>typeof r === "string") : undefined;
48
+ return {
49
+ ...payload,
50
+ sub,
51
+ roles: roles && roles.length > 0 ? roles : undefined,
52
+ scopes
53
+ };
54
+ }
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ Object.defineProperty(exports, "AuthConfig", {
6
+ enumerable: true,
7
+ get: function() {
8
+ return AuthConfig;
9
+ }
10
+ });
11
+ class AuthConfig {
12
+ /** OIDC discovery URL — the issuer base URL, e.g. https://accounts.google.com.
13
+ * The framework appends `/.well-known/openid-configuration` automatically.
14
+ * Use this instead of `jwksUrl` for any standard OIDC-compliant IdP.
15
+ */ discoveryUrl;
16
+ /** JWKS endpoint URL (RS256 / ES256 via remote key set). */ jwksUrl;
17
+ /** PEM-encoded public key or JWK JSON string (RS256 / ES256 static key). */ publicKey;
18
+ algorithms;
19
+ /** Shared secret for HS256. */ secret;
20
+ /** Issuer claim to validate (all non-devStatic variants). */ issuer;
21
+ /** Audience claim to validate (all non-devStatic variants). */ audience;
22
+ /** Static principal injected for every request (development only). */ devStatic;
23
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", {
3
+ value: true
4
+ });
5
+ function _export(target, all) {
6
+ for(var name in all)Object.defineProperty(target, name, {
7
+ enumerable: true,
8
+ get: Object.getOwnPropertyDescriptor(all, name).get
9
+ });
10
+ }
11
+ _export(exports, {
12
+ get ADMIN_ROLE () {
13
+ return _ability.ADMIN_ROLE;
14
+ },
15
+ get BuilderExtensions () {
16
+ return BuilderExtensions;
17
+ },
18
+ get createPrincipalFromPayload () {
19
+ return _ability.createPrincipalFromPayload;
20
+ },
21
+ get defineAbilitiesFor () {
22
+ return _ability.defineAbilitiesFor;
23
+ }
24
+ });
25
+ const _extensibility = require("@decohere/core/extensibility");
26
+ const _builder = require("../builder/builder.js");
27
+ const _const = require("../const.js");
28
+ const _ability = require("./ability.js");
29
+ const _authconfig = require("./auth.config.js");
30
+ const _authtypes = require("./auth.types.js");
31
+ const _caslauthorizationservice = require("./casl-authorization.service.js");
32
+ const _tokenservice = require("./token-service.js");
33
+ function _ts_decorate(decorators, target, key, desc) {
34
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
35
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
36
+ else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
37
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
38
+ }
39
+ function _ts_metadata(k, v) {
40
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
41
+ }
42
+ class BuilderExtensions {
43
+ static withAuth(builder, abilityFactory = _ability.defineAbilitiesFor, options) {
44
+ builder.registerPreBuildStep(async (configService, containerBuilder)=>{
45
+ const authConfig = await configService.get(_authconfig.AuthConfig);
46
+ const tokenConfig = toTokenServiceConfig(authConfig);
47
+ const tokenService = new _tokenservice.TokenService(tokenConfig, options?.principalMapper);
48
+ containerBuilder.registerSingleton(_const.TokenServiceToken, tokenService);
49
+ containerBuilder.registerSingleton(_const.AuthorizationServiceToken, new _caslauthorizationservice.CaslAuthorizationService(abilityFactory, tokenService));
50
+ });
51
+ return builder;
52
+ }
53
+ }
54
+ _ts_decorate([
55
+ (0, _extensibility.extension)(_builder.Builder),
56
+ _ts_metadata("design:type", Function),
57
+ _ts_metadata("design:paramtypes", [
58
+ typeof _builder.Builder === "undefined" ? Object : _builder.Builder,
59
+ typeof AbilityFactory === "undefined" ? Object : AbilityFactory,
60
+ typeof WithAuthOptions === "undefined" ? Object : WithAuthOptions
61
+ ]),
62
+ _ts_metadata("design:returntype", void 0)
63
+ ], BuilderExtensions, "withAuth", null);
64
+ function toTokenServiceConfig(config) {
65
+ // Prefer real verification strategies over devStatic so a leftover
66
+ // development block cannot disable OIDC/JWKS in any environment.
67
+ if (config.discoveryUrl != null) {
68
+ (0, _authtypes.assertNonEmptyAudience)(config.audience);
69
+ return {
70
+ discoveryUrl: config.discoveryUrl,
71
+ audience: config.audience
72
+ };
73
+ }
74
+ if (config.jwksUrl != null) {
75
+ (0, _authtypes.assertNonEmptyAudience)(config.audience);
76
+ return {
77
+ jwksUrl: config.jwksUrl,
78
+ issuer: config.issuer,
79
+ audience: config.audience
80
+ };
81
+ }
82
+ if (config.publicKey != null) {
83
+ (0, _authtypes.assertNonEmptyAudience)(config.audience);
84
+ return {
85
+ publicKey: config.publicKey,
86
+ algorithms: config.algorithms,
87
+ issuer: config.issuer,
88
+ audience: config.audience
89
+ };
90
+ }
91
+ if (config.secret != null) {
92
+ (0, _authtypes.assertNonEmptyAudience)(config.audience);
93
+ return {
94
+ secret: config.secret,
95
+ issuer: config.issuer,
96
+ audience: config.audience
97
+ };
98
+ }
99
+ if (config.devStatic != null) {
100
+ (0, _authtypes.assertDevStaticAllowed)();
101
+ return {
102
+ devStatic: config.devStatic
103
+ };
104
+ }
105
+ throw new Error("AuthConfig must specify one of: discoveryUrl, jwksUrl, publicKey, secret, or devStatic");
106
+ }