@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,63 @@
1
+ import type { ContainerBuilder } from "@decohere/di";
2
+ import type { Constructable } from "@decohere/core/reflection";
3
+ import type { App } from "../app/app.js";
4
+ import { Builder } from "../builder/builder.js";
5
+ import "../http/http.extensions.js";
6
+ import { FastifyProvider } from "../providers/fastify.provider.js";
7
+ import type { HttpProvider } from "../routing/anvil.types.js";
8
+ import {
9
+ type EventStreamRead,
10
+ type ReadEventsOptions,
11
+ readEventStream,
12
+ } from "./sse-reader.js";
13
+
14
+ export type BootModuleOptions = {
15
+ controllers: Constructable[];
16
+ /** Register services the controllers need. */
17
+ services?: (containerBuilder: ContainerBuilder) => void;
18
+ /** Add anything else to the builder (`withAuth()`, `withOpenApi()`, ...) before the app is built. */
19
+ configure?: (builder: Builder) => unknown;
20
+ /** Defaults to the Fastify provider. */
21
+ provider?: Constructable<HttpProvider>;
22
+ };
23
+
24
+ export type BootedModule = AsyncDisposable & {
25
+ app: App;
26
+ /** Origin of the listening app, e.g. `http://127.0.0.1:41233`. */
27
+ baseUrl: string;
28
+ /** `GET path` as an `EventSource` would, and collect the first `count` events. */
29
+ readEvents(path: string, options?: ReadEventsOptions): Promise<EventStreamRead>;
30
+ /** Stop the app (closing any open streams). Same as `await using`. */
31
+ stop(): Promise<void>;
32
+ };
33
+
34
+ /**
35
+ * Boot the given controllers as a real app on a free loopback port, so a test can call it over
36
+ * HTTP. The app runs the full pipeline: routing, auth, middleware, and the real provider.
37
+ *
38
+ * @example
39
+ * await using api = await bootModule({ controllers: [PriceController] });
40
+ * const { events } = await api.readEvents("/prices/stream", { count: 3 });
41
+ */
42
+ export async function bootModule(options: BootModuleOptions): Promise<BootedModule> {
43
+ let builder = Builder.create()
44
+ .withHttp(options.provider ?? FastifyProvider)
45
+ .withControllers(...options.controllers);
46
+ if (options.services) builder = builder.withServices(options.services);
47
+ options.configure?.(builder);
48
+ const app = await builder.build();
49
+ await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
50
+ const baseUrl = app.addresses[0];
51
+ if (baseUrl == null) {
52
+ await app.dispose();
53
+ throw new Error("app.start({ port: 0 }) advertised no IPv4 address");
54
+ }
55
+ const stop = () => app.dispose();
56
+ return {
57
+ app,
58
+ baseUrl,
59
+ readEvents: (path, readOptions) => readEventStream(`${baseUrl}${path}`, readOptions),
60
+ stop,
61
+ [Symbol.asyncDispose]: stop,
62
+ };
63
+ }
@@ -0,0 +1,120 @@
1
+ export type ReceivedEvent = {
2
+ /** The `data:` lines joined with `\n`, as `EventSource` would give them. */
3
+ data: string;
4
+ event?: string;
5
+ id?: string;
6
+ retry?: number;
7
+ };
8
+
9
+ export type EventStreamRead = {
10
+ status: number;
11
+ headers: Headers;
12
+ /** The events received, at most the requested count. */
13
+ events: ReceivedEvent[];
14
+ /** Comment lines (`: heartbeat` and the like) seen before the read ended. */
15
+ comments: string[];
16
+ /** True when the server ended the stream (as opposed to the reader stopping at the count). */
17
+ ended: boolean;
18
+ };
19
+
20
+ export type ReadEventsOptions = {
21
+ /** Stop and close the connection after this many events. Default 1. `Infinity` reads until the server ends the stream. */
22
+ count?: number;
23
+ headers?: Record<string, string>;
24
+ /** Give up (and fail) if the read has not finished after this many milliseconds. Default 5000. */
25
+ timeoutMs?: number;
26
+ };
27
+
28
+ /** Incremental parser for the `text/event-stream` format. */
29
+ class SseParser {
30
+ private buffer = "";
31
+ private data: string[] = [];
32
+ private current: ReceivedEvent = { data: "" };
33
+ private hasFields = false;
34
+
35
+ public readonly events: ReceivedEvent[] = [];
36
+ public readonly comments: string[] = [];
37
+
38
+ public push(chunk: string): void {
39
+ this.buffer += chunk;
40
+ let newline: number;
41
+ while ((newline = this.buffer.search(/\r\n|\r|\n/)) !== -1) {
42
+ const line = this.buffer.slice(0, newline);
43
+ const eol = this.buffer.slice(newline).startsWith("\r\n") ? 2 : 1;
44
+ this.buffer = this.buffer.slice(newline + eol);
45
+ this.line(line);
46
+ }
47
+ }
48
+
49
+ private line(line: string): void {
50
+ if (line === "") {
51
+ if (this.hasFields) {
52
+ this.events.push({ ...this.current, data: this.data.join("\n") });
53
+ }
54
+ this.data = [];
55
+ this.current = { data: "" };
56
+ this.hasFields = false;
57
+ return;
58
+ }
59
+ if (line.startsWith(":")) {
60
+ this.comments.push(line.slice(1).trim());
61
+ return;
62
+ }
63
+ const colon = line.indexOf(":");
64
+ const field = colon === -1 ? line : line.slice(0, colon);
65
+ let value = colon === -1 ? "" : line.slice(colon + 1);
66
+ if (value.startsWith(" ")) value = value.slice(1);
67
+ this.hasFields = true;
68
+ if (field === "data") this.data.push(value);
69
+ else if (field === "event") this.current.event = value;
70
+ else if (field === "id") this.current.id = value;
71
+ else if (field === "retry") this.current.retry = Number(value);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * GET `url` as an `EventSource` would and collect events until `count` have arrived or the
77
+ * server ends the stream. A non-200 answer (401, 403, ...) returns at once with no events.
78
+ */
79
+ export async function readEventStream(
80
+ url: string,
81
+ options: ReadEventsOptions = {},
82
+ ): Promise<EventStreamRead> {
83
+ const count = options.count ?? 1;
84
+ const abort = new AbortController();
85
+ const timer = setTimeout(
86
+ () => abort.abort(new Error(`No ${count} events from ${url} in time`)),
87
+ options.timeoutMs ?? 5000,
88
+ );
89
+ try {
90
+ const response = await fetch(url, {
91
+ headers: { accept: "text/event-stream", ...options.headers },
92
+ signal: abort.signal,
93
+ });
94
+ const parser = new SseParser();
95
+ let ended = false;
96
+ if (response.status === 200 && response.body != null) {
97
+ const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
98
+ while (parser.events.length < count) {
99
+ const { done, value } = await reader.read();
100
+ if (done) {
101
+ ended = true;
102
+ break;
103
+ }
104
+ parser.push(value);
105
+ }
106
+ await reader.cancel();
107
+ } else {
108
+ await response.body?.cancel();
109
+ }
110
+ return {
111
+ status: response.status,
112
+ headers: response.headers,
113
+ events: parser.events.slice(0, count),
114
+ comments: parser.comments,
115
+ ended,
116
+ };
117
+ } finally {
118
+ clearTimeout(timer);
119
+ }
120
+ }
@@ -0,0 +1,292 @@
1
+ import { inject, injectable } from "@decohere/di/decorators";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { Builder } from "../src/builder/builder.js";
5
+ import type { HostedService } from "../src/hosting/hosted-service.js";
6
+ import "../src/health/health.extensions.js";
7
+ import "../src/http/http.extensions.js";
8
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
9
+
10
+ /** What happened, in order, across every service and the app in one test. */
11
+ let events: string[] = [];
12
+
13
+ const DbToken = Symbol("Db");
14
+
15
+ /** Stands in for a connection pool: closed when the app's container is disposed. */
16
+ @injectable()
17
+ class Db {
18
+ public async [Symbol.asyncDispose]() {
19
+ events.push("db closed");
20
+ }
21
+ }
22
+
23
+ /** Records its lifecycle. Subclassed, not built by a factory: the container keys a class by name. */
24
+ abstract class Recording implements HostedService {
25
+ protected abstract readonly label: string;
26
+ public constructor(private readonly db: Db) {}
27
+ public start(signal: AbortSignal) {
28
+ signal.addEventListener("abort", () => events.push(`${this.label} signal aborted`));
29
+ events.push(`${this.label} started (db ${this.db == null ? "missing" : "ready"})`);
30
+ }
31
+ public stop() {
32
+ events.push(`${this.label} stopped`);
33
+ }
34
+ }
35
+
36
+ @injectable()
37
+ class First extends Recording {
38
+ protected readonly label = "first";
39
+ public constructor(@inject(DbToken) db: Db) {
40
+ super(db);
41
+ }
42
+ }
43
+
44
+ @injectable()
45
+ class Second extends Recording {
46
+ protected readonly label = "second";
47
+ public constructor(@inject(DbToken) db: Db) {
48
+ super(db);
49
+ }
50
+ }
51
+
52
+ /** A worker that ticks on a timer until its signal is aborted, like a feed refresh loop. */
53
+ @injectable()
54
+ class Ticker implements HostedService {
55
+ public ticks = 0;
56
+ public start(signal: AbortSignal) {
57
+ const timer = setInterval(() => {
58
+ this.ticks += 1;
59
+ }, 5);
60
+ signal.addEventListener("abort", () => clearInterval(timer));
61
+ }
62
+ }
63
+
64
+ function builder() {
65
+ return Builder.create().withServices((c) => c.registerSingleton(DbToken, Db));
66
+ }
67
+
68
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
69
+
70
+ describe("hosted services (#541)", () => {
71
+ let app: App | undefined;
72
+
73
+ afterEach(async () => {
74
+ await app?.dispose();
75
+ app = undefined;
76
+ events = [];
77
+ });
78
+
79
+ it("starts each service once, in registration order, before the app listens", async () => {
80
+ @injectable()
81
+ class SeesAddresses implements HostedService {
82
+ public constructor(@inject(DbToken) private readonly db: Db) {}
83
+ public start() {
84
+ events.push(`listening during start: ${String(appRef?.addresses.length ?? 0)}`);
85
+ void this.db;
86
+ }
87
+ }
88
+ let appRef: App | undefined;
89
+ app = await builder()
90
+ .withHttp(FastifyProvider)
91
+ .withHostedService(First, Second, SeesAddresses)
92
+ .build();
93
+ appRef = app;
94
+
95
+ await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
96
+
97
+ expect(events).toEqual([
98
+ "first started (db ready)",
99
+ "second started (db ready)",
100
+ "listening during start: 0",
101
+ ]);
102
+ expect(app.addresses).toHaveLength(1);
103
+ });
104
+
105
+ it("stops in reverse order: signal first, then each stop(), then the container", async () => {
106
+ app = await builder().withHttp(FastifyProvider).withHostedService(First, Second).build();
107
+ await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
108
+ events = [];
109
+
110
+ await app.dispose();
111
+
112
+ expect(events).toEqual([
113
+ "first signal aborted",
114
+ "second signal aborted",
115
+ "second stopped",
116
+ "first stopped",
117
+ "db closed",
118
+ ]);
119
+ });
120
+
121
+ it("runs in an app with no HTTP server", async () => {
122
+ app = await builder().withHostedService(First).build();
123
+
124
+ await app.start({ port: 0, shutdownSignals: false });
125
+ await app.dispose();
126
+
127
+ expect(events).toEqual([
128
+ "first started (db ready)",
129
+ "first signal aborted",
130
+ "first stopped",
131
+ "db closed",
132
+ ]);
133
+ });
134
+
135
+ // Flux reboots an app by calling its default export's Symbol.asyncDispose.
136
+ it("leaves no timer running after a Flux reboot (Symbol.asyncDispose)", async () => {
137
+ app = await builder().withHostedService(Ticker).build();
138
+ await app.start({ port: 0, shutdownSignals: false });
139
+ await sleep(30);
140
+ const ticker = (
141
+ app as unknown as { container: { resolve(t: unknown): Ticker } }
142
+ ).container.resolve(Ticker);
143
+ expect(ticker.ticks).toBeGreaterThan(0);
144
+
145
+ await app[Symbol.asyncDispose]();
146
+ const ticksAtStop = ticker.ticks;
147
+ await sleep(30);
148
+
149
+ expect(ticker.ticks).toBe(ticksAtStop);
150
+ });
151
+
152
+ it("is the same instance when injected by its class", async () => {
153
+ @injectable()
154
+ class ReadsTicker {
155
+ public constructor(@inject(Ticker) public readonly ticker: Ticker) {}
156
+ }
157
+ app = await builder()
158
+ .withHostedService(Ticker)
159
+ .withServices((c) => c.registerSingleton(ReadsTicker))
160
+ .build();
161
+ await app.start({ port: 0, shutdownSignals: false });
162
+ await sleep(20);
163
+
164
+ const container = (app as unknown as { container: { resolve<T>(t: unknown): T } })
165
+ .container;
166
+ const reader = container.resolve<ReadsTicker>(ReadsTicker);
167
+
168
+ expect(reader.ticker.ticks).toBeGreaterThan(0);
169
+ });
170
+
171
+ it("does not wait for background work started by start()", async () => {
172
+ @injectable()
173
+ class SlowWarmUp implements HostedService {
174
+ public warm = false;
175
+ public start(signal: AbortSignal) {
176
+ // The ledger position feed: minutes of loading, started in the background.
177
+ void sleep(10_000).then(() => {
178
+ if (!signal.aborted) this.warm = true;
179
+ });
180
+ }
181
+ }
182
+ app = await builder().withHttp(FastifyProvider).withHostedService(SlowWarmUp).build();
183
+
184
+ const started = Date.now();
185
+ await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
186
+
187
+ expect(Date.now() - started).toBeLessThan(2_000);
188
+ expect(app.addresses).toHaveLength(1);
189
+ });
190
+
191
+ it("aborts the signal of a start() still running when the app stops, then stops it", async () => {
192
+ let release: () => void = () => {};
193
+ @injectable()
194
+ class Starting implements HostedService {
195
+ public async start(signal: AbortSignal) {
196
+ events.push("start begun");
197
+ signal.addEventListener("abort", () => events.push("signal aborted"));
198
+ await new Promise<void>((resolve) => {
199
+ release = resolve;
200
+ });
201
+ events.push("start finished");
202
+ }
203
+ public stop() {
204
+ events.push("stopped");
205
+ }
206
+ }
207
+ app = await builder().withHostedService(Starting).build();
208
+
209
+ const starting = app.start({ port: 0, shutdownSignals: false });
210
+ await sleep(5);
211
+ const stopping = app.dispose();
212
+ await sleep(5);
213
+ release();
214
+ await Promise.allSettled([starting, stopping]);
215
+
216
+ expect(events).toEqual(["start begun", "signal aborted", "start finished", "stopped"]);
217
+ });
218
+
219
+ it("stops the services already started when a later start() fails, and fails app.start", async () => {
220
+ @injectable()
221
+ class Broken implements HostedService {
222
+ public start() {
223
+ events.push("broken started");
224
+ throw new Error("feed unreachable");
225
+ }
226
+ public stop() {
227
+ events.push("broken stopped");
228
+ }
229
+ }
230
+ app = await builder()
231
+ .withHttp(FastifyProvider)
232
+ .withHostedService(First, Broken, Second)
233
+ .build();
234
+
235
+ await expect(
236
+ app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false }),
237
+ ).rejects.toThrow("feed unreachable");
238
+
239
+ expect(events).toEqual([
240
+ "first started (db ready)",
241
+ "broken started",
242
+ "first signal aborted",
243
+ "first stopped",
244
+ ]);
245
+ expect(app.addresses).toHaveLength(0);
246
+ });
247
+
248
+ it("keeps stopping the others when one stop() throws", async () => {
249
+ @injectable()
250
+ class FailsToStop implements HostedService {
251
+ public start() {}
252
+ public stop() {
253
+ throw new Error("stuck");
254
+ }
255
+ }
256
+ app = await builder().withHostedService(First, FailsToStop, Second).build();
257
+ await app.start({ port: 0, shutdownSignals: false });
258
+ events = [];
259
+
260
+ await expect(app.dispose()).rejects.toThrow("stuck");
261
+ app = undefined; // dispose() keeps returning that rejection
262
+
263
+ expect(events).toContain("second stopped");
264
+ expect(events).toContain("first stopped");
265
+ expect(events.at(-1)).toBe("db closed");
266
+ });
267
+
268
+ it("reports a warming service as not ready on /ready until its check() passes", async () => {
269
+ @injectable()
270
+ class Warming implements HostedService {
271
+ public warm = false;
272
+ public start() {}
273
+ public async check() {
274
+ if (!this.warm) throw new Error("still loading positions");
275
+ }
276
+ }
277
+ app = await builder()
278
+ .withHttp(FastifyProvider)
279
+ .withHealth()
280
+ .withHostedService(Warming)
281
+ .build();
282
+ await app.start({ port: 0, host: "127.0.0.1", shutdownSignals: false });
283
+ const url = `${app.addresses[0]}/ready`;
284
+
285
+ expect((await fetch(url)).status).toBe(503);
286
+ const container = (app as unknown as { container: { resolve<T>(t: unknown): T } })
287
+ .container;
288
+ container.resolve<Warming>(Warming).warm = true;
289
+ expect((await fetch(url)).status).toBe(200);
290
+ expect((await fetch(`${app.addresses[0]}/health`)).status).toBe(200);
291
+ });
292
+ });
@@ -0,0 +1,160 @@
1
+ import { createConnection } from "node:net";
2
+ import { inject, injectable } from "@decohere/di/decorators";
3
+ import axios from "axios";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import type { App } from "../src/app/app.js";
6
+ import { Builder } from "../src/builder/builder.js";
7
+ import "../src/http/http.extensions.js";
8
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
9
+ import { Controller, Get } from "../src/routing/index.js";
10
+
11
+ const DbToken = Symbol("Db");
12
+
13
+ /** Stands in for a connection pool: closed when the app's container is disposed. */
14
+ @injectable()
15
+ class Db {
16
+ public closed = false;
17
+ public async [Symbol.asyncDispose]() {
18
+ this.closed = true;
19
+ }
20
+ }
21
+
22
+ @Controller({ prefix: "/slow" })
23
+ class SlowController {
24
+ public constructor(@inject(DbToken) private readonly db: Db) {}
25
+
26
+ @Get("/wait")
27
+ public async slow() {
28
+ await new Promise((resolve) => setTimeout(resolve, 150));
29
+ return { dbClosedDuringRequest: this.db.closed };
30
+ }
31
+ }
32
+
33
+ /** Open (and close) a raw TCP connection — no HTTP agent that might reuse a kept-alive socket. */
34
+ function connect(baseURL: string): Promise<void> {
35
+ const { hostname, port } = new URL(baseURL);
36
+ return new Promise((resolve, reject) => {
37
+ const socket = createConnection({ host: hostname, port: Number(port) });
38
+ socket.once("connect", () => {
39
+ socket.destroy();
40
+ resolve();
41
+ });
42
+ socket.once("error", reject);
43
+ });
44
+ }
45
+
46
+ async function buildApp(): Promise<App> {
47
+ return Builder.create()
48
+ .withHttp(FastifyProvider)
49
+ .withServices((c) => c.registerSingleton(DbToken, Db))
50
+ .withControllers(SlowController)
51
+ .build();
52
+ }
53
+
54
+ /** The listener `app.start()` added for `signal` (invoked directly; emitting would hit the test runner too). */
55
+ async function startCapturing(app: App, signal: NodeJS.Signals, options = {}) {
56
+ const before = new Set(process.listeners(signal));
57
+ await app.start({ port: 0, host: "127.0.0.1", ...options });
58
+ const added = process.listeners(signal).filter((listener) => !before.has(listener));
59
+ return {
60
+ baseURL: app.addresses[0] as string,
61
+ handler: added[0] as (s: NodeJS.Signals) => void,
62
+ added,
63
+ };
64
+ }
65
+
66
+ describe("graceful shutdown on SIGTERM / SIGINT (#473)", () => {
67
+ let app: App | undefined;
68
+ /** Cancels requests that never answer, so the server can close in afterEach. */
69
+ let hung: AbortController;
70
+
71
+ beforeEach(() => {
72
+ hung = new AbortController();
73
+ });
74
+
75
+ afterEach(async () => {
76
+ hung.abort();
77
+ await app?.dispose();
78
+ app = undefined;
79
+ vi.restoreAllMocks();
80
+ });
81
+
82
+ it("lets an in-flight request finish before closing dependencies, then stops listening", async () => {
83
+ app = await buildApp();
84
+ const { baseURL, handler } = await startCapturing(app, "SIGTERM");
85
+
86
+ const inFlight = axios.get(`${baseURL}/slow/wait`);
87
+ await new Promise((resolve) => setTimeout(resolve, 30));
88
+ handler("SIGTERM");
89
+
90
+ const res = await inFlight;
91
+ expect(res.status).toBe(200);
92
+ expect(res.data).toEqual({ dbClosedDuringRequest: false });
93
+
94
+ await app.dispose(); // same shutdown already under way
95
+ await expect(connect(baseURL)).rejects.toMatchObject({ code: "ECONNREFUSED" });
96
+ });
97
+
98
+ it("handles SIGINT too, and removes both listeners once stopped", async () => {
99
+ app = await buildApp();
100
+ const intBefore = process.listeners("SIGINT").length;
101
+ const term = await startCapturing(app, "SIGTERM");
102
+ expect(term.added).toHaveLength(1);
103
+ expect(process.listeners("SIGINT")).toHaveLength(intBefore + 1);
104
+
105
+ await app.dispose();
106
+ expect(process.listeners("SIGTERM")).not.toContain(term.handler);
107
+ expect(process.listeners("SIGINT")).toHaveLength(intBefore);
108
+ });
109
+
110
+ it("registers nothing when shutdownSignals is false", async () => {
111
+ app = await buildApp();
112
+ const { added } = await startCapturing(app, "SIGTERM", { shutdownSignals: false });
113
+ expect(added).toHaveLength(0);
114
+ });
115
+
116
+ it("exits with code 1 when stopping takes longer than shutdownTimeoutMs", async () => {
117
+ // Signalled once the request is in the handler: a fixed sleep before SIGTERM let a slow
118
+ // machine stop before the request arrived, so nothing was in flight and it exited 0.
119
+ let arrived = () => {};
120
+ const inHandler = new Promise<void>((resolve) => {
121
+ arrived = resolve;
122
+ });
123
+ @Controller({ prefix: "/hang" })
124
+ class HangController {
125
+ @Get("/wait")
126
+ public hang() {
127
+ arrived();
128
+ return new Promise(() => {});
129
+ }
130
+ }
131
+ app = await Builder.create()
132
+ .withHttp(FastifyProvider)
133
+ .withControllers(HangController)
134
+ .build();
135
+ const exit = vi.spyOn(process, "exit").mockImplementation((() => undefined) as never);
136
+ const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
137
+ const { baseURL, handler } = await startCapturing(app, "SIGTERM", {
138
+ shutdownTimeoutMs: 50,
139
+ });
140
+
141
+ axios.get(`${baseURL}/hang/wait`, { signal: hung.signal }).catch(() => {});
142
+ await inHandler;
143
+ handler("SIGTERM");
144
+
145
+ await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1), { timeout: 1000 });
146
+ expect(stderr.mock.calls.flat().join(" ")).toContain("50ms");
147
+ });
148
+
149
+ it("stops listening for signals as soon as shutdown starts, so a second one exits the default way", async () => {
150
+ app = await buildApp();
151
+ const { handler } = await startCapturing(app, "SIGTERM");
152
+ const intBefore = process.listeners("SIGINT").length;
153
+
154
+ handler("SIGTERM");
155
+
156
+ expect(process.listeners("SIGTERM")).not.toContain(handler);
157
+ expect(process.listeners("SIGINT")).toHaveLength(intBefore - 1);
158
+ await app.dispose();
159
+ });
160
+ });