@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,421 @@
1
+ import { setTimeout as sleep } from "node:timers/promises";
2
+ import type { ContainerBuilder } from "@decohere/di";
3
+ import { inject, injectable } from "@decohere/di/decorators";
4
+ import type { IConfigurationService } from "@decohere/core/configuration";
5
+ import { ConfigurationServiceToken } from "@decohere/core/configuration";
6
+ import type { Constructable } from "@decohere/core/reflection";
7
+ import { describe, expect, it } from "vitest";
8
+ import type { OpenApiDocument } from "../src/openapi/openapi.types.js";
9
+ import { AuthConfig } from "../src/auth/auth.config.js";
10
+ import "../src/auth/auth.extensions.js";
11
+ import { TokenService } from "../src/auth/token-service.js";
12
+ import {
13
+ AllowAnonymous,
14
+ Authorize,
15
+ Controller,
16
+ Get,
17
+ LastEventId,
18
+ Signal,
19
+ Sse,
20
+ type SseStream,
21
+ } from "../src/index.js";
22
+ import { bootModule } from "../src/testing/index.js";
23
+
24
+ /** Records what the producers did, so tests can see that cleanup ran. */
25
+ class Probe {
26
+ public started = 0;
27
+ public timersRunning = 0;
28
+ public signalAborted = false;
29
+ public lastEventId: string | undefined;
30
+ public scopeDisposed = false;
31
+ }
32
+ const probe = new Probe();
33
+
34
+ /** Request-scoped: lives as long as the stream, disposed when it ends. */
35
+ @injectable()
36
+ class RequestResource {
37
+ public constructor() {
38
+ probe.scopeDisposed = false;
39
+ }
40
+ public async [Symbol.asyncDispose]() {
41
+ probe.scopeDisposed = true;
42
+ }
43
+ }
44
+
45
+ /** A producer that owns a timer, like a real subscription would. */
46
+ async function* ticker(signal: AbortSignal, intervalMs: number): AsyncGenerator<string> {
47
+ probe.started += 1;
48
+ probe.timersRunning += 1;
49
+ let tick = 0;
50
+ try {
51
+ while (!signal.aborted) {
52
+ await new Promise<void>((resolve) => {
53
+ const timer = setTimeout(resolve, intervalMs);
54
+ signal.addEventListener(
55
+ "abort",
56
+ () => {
57
+ clearTimeout(timer);
58
+ resolve();
59
+ },
60
+ { once: true },
61
+ );
62
+ });
63
+ if (!signal.aborted) yield `tick ${++tick}`;
64
+ }
65
+ } finally {
66
+ probe.timersRunning -= 1;
67
+ probe.signalAborted = signal.aborted;
68
+ }
69
+ }
70
+
71
+ @Controller({ prefix: "/feed" })
72
+ class FeedController {
73
+ public constructor(@inject(RequestResource) private readonly resource: RequestResource) {}
74
+
75
+ @Sse()
76
+ @Get("/items")
77
+ public async *items(): SseStream {
78
+ probe.started += 1;
79
+ yield { id: "1", event: "item", data: { name: "first" } };
80
+ yield { id: "2", event: "item", data: { name: "second" } };
81
+ yield "plain text";
82
+ yield { data: "line one\nline two" };
83
+ }
84
+
85
+ @Sse()
86
+ @Get("/resume")
87
+ public async *resume(@LastEventId() lastEventId?: string): SseStream {
88
+ probe.lastEventId = lastEventId;
89
+ const from = lastEventId == null ? 1 : Number(lastEventId) + 1;
90
+ for (const id of [from, from + 1]) {
91
+ yield { id: String(id), data: `event ${id}` };
92
+ }
93
+ }
94
+
95
+ @Sse({ heartbeatMs: 20 })
96
+ @Get("/quiet")
97
+ public async *quiet(@Signal() signal: AbortSignal): SseStream {
98
+ yield "hello";
99
+ await sleep(100, undefined, { signal });
100
+ yield "world";
101
+ }
102
+
103
+ @Sse({ heartbeatMs: 0 })
104
+ @Get("/live")
105
+ public async *live(@Signal() signal: AbortSignal): SseStream {
106
+ void this.resource;
107
+ yield { id: "0", data: "connected" };
108
+ for await (const message of ticker(signal, 10)) yield message;
109
+ }
110
+
111
+ @Sse()
112
+ @Get("/broken")
113
+ public async *broken(): SseStream {
114
+ yield "before";
115
+ throw new Error("producer exploded");
116
+ }
117
+
118
+ @Sse()
119
+ @Get("/not-a-stream")
120
+ public notAStream() {
121
+ return { oops: true };
122
+ }
123
+
124
+ @Get("/plain")
125
+ public plain() {
126
+ return { ok: true };
127
+ }
128
+ }
129
+
130
+ describe("server-sent events from a controller route (#629)", () => {
131
+ it("writes the handler's events as text/event-stream", async () => {
132
+ await using api = await bootModule({
133
+ controllers: [FeedController],
134
+ services: services(),
135
+ });
136
+
137
+ const read = await api.readEvents("/feed/items", { count: 4 });
138
+
139
+ expect(read.status).toBe(200);
140
+ expect(read.headers.get("content-type")).toContain("text/event-stream");
141
+ expect(read.headers.get("cache-control")).toContain("no-cache");
142
+ expect(read.events).toEqual([
143
+ { id: "1", event: "item", data: '{"name":"first"}' },
144
+ { id: "2", event: "item", data: '{"name":"second"}' },
145
+ { data: "plain text" },
146
+ { data: "line one\nline two" },
147
+ ]);
148
+ });
149
+
150
+ it("ends the response when the handler finishes", async () => {
151
+ await using api = await bootModule({
152
+ controllers: [FeedController],
153
+ services: services(),
154
+ });
155
+
156
+ const read = await api.readEvents("/feed/items", { count: Infinity });
157
+
158
+ expect(read.ended).toBe(true);
159
+ expect(read.events).toHaveLength(4);
160
+ });
161
+
162
+ it("gives the handler the Last-Event-ID the browser sends when it reconnects", async () => {
163
+ await using api = await bootModule({
164
+ controllers: [FeedController],
165
+ services: services(),
166
+ });
167
+
168
+ const first = await api.readEvents("/feed/resume", { count: 2 });
169
+ expect(first.events.map((e) => e.id)).toEqual(["1", "2"]);
170
+ expect(probe.lastEventId).toBeUndefined();
171
+
172
+ const resumed = await api.readEvents("/feed/resume", {
173
+ count: 2,
174
+ headers: { "Last-Event-ID": "2" },
175
+ });
176
+ expect(probe.lastEventId).toBe("2");
177
+ expect(resumed.events.map((e) => e.id)).toEqual(["3", "4"]);
178
+ });
179
+
180
+ it("writes a heartbeat comment while the producer is quiet", async () => {
181
+ await using api = await bootModule({
182
+ controllers: [FeedController],
183
+ services: services(),
184
+ });
185
+
186
+ const read = await api.readEvents("/feed/quiet", { count: 2 });
187
+
188
+ expect(read.events.map((e) => e.data)).toEqual(["hello", "world"]);
189
+ expect(read.comments.filter((c) => c === "heartbeat").length).toBeGreaterThanOrEqual(2);
190
+ });
191
+
192
+ it("ends the stream without sending a half-built event when the producer throws", async () => {
193
+ await using api = await bootModule({
194
+ controllers: [FeedController],
195
+ services: services(),
196
+ });
197
+
198
+ const read = await api.readEvents("/feed/broken", { count: Infinity });
199
+
200
+ expect(read.events).toEqual([{ data: "before" }]);
201
+ expect(read.ended).toBe(true);
202
+ });
203
+
204
+ it("answers 500 for an @Sse() method that does not return a stream", async () => {
205
+ await using api = await bootModule({
206
+ controllers: [FeedController],
207
+ services: services(),
208
+ });
209
+
210
+ const read = await api.readEvents("/feed/not-a-stream");
211
+
212
+ expect(read.status).toBe(500);
213
+ expect(read.events).toEqual([]);
214
+ });
215
+
216
+ it("leaves ordinary routes alone", async () => {
217
+ await using api = await bootModule({
218
+ controllers: [FeedController],
219
+ services: services(),
220
+ });
221
+
222
+ const response = await fetch(`${api.baseUrl}/feed/plain`);
223
+
224
+ expect(response.headers.get("content-type")).toContain("application/json");
225
+ expect(await response.json()).toEqual({ ok: true });
226
+ });
227
+ });
228
+
229
+ describe("SSE: client disconnect and app stop", () => {
230
+ it("stops the producer, its timer and the request scope when the client disconnects", async () => {
231
+ await using api = await bootModule({
232
+ controllers: [FeedController],
233
+ services: services(),
234
+ });
235
+ const timersBefore = probe.timersRunning;
236
+ probe.signalAborted = false;
237
+
238
+ const read = await api.readEvents("/feed/live", { count: 2 });
239
+
240
+ expect(read.events.length).toBe(2);
241
+ await waitFor(() => probe.timersRunning === timersBefore);
242
+ expect(probe.signalAborted).toBe(true);
243
+ await waitFor(() => probe.scopeDisposed);
244
+ });
245
+
246
+ it("keeps request-scoped services alive while the stream is running", async () => {
247
+ await using api = await bootModule({
248
+ controllers: [FeedController],
249
+ services: services(),
250
+ });
251
+
252
+ const reading = api.readEvents("/feed/live", { count: Infinity, timeoutMs: 4000 });
253
+ await sleep(80);
254
+
255
+ expect(probe.scopeDisposed).toBe(false);
256
+ await api.stop();
257
+ await reading;
258
+ expect(probe.scopeDisposed).toBe(true);
259
+ });
260
+
261
+ it("closes open streams when the app stops, so stopping does not hang", async () => {
262
+ const api = await bootModule({ controllers: [FeedController], services: services() });
263
+ const timersBefore = probe.timersRunning;
264
+
265
+ const reading = api.readEvents("/feed/live", { count: Infinity, timeoutMs: 4000 });
266
+ await waitFor(() => probe.timersRunning > timersBefore);
267
+
268
+ await api.stop();
269
+ const read = await reading;
270
+
271
+ expect(read.ended).toBe(true);
272
+ expect(probe.timersRunning).toBe(timersBefore);
273
+ expect(probe.signalAborted).toBe(true);
274
+ });
275
+
276
+ it("closes open streams on the dispose path Flux uses for a reboot", async () => {
277
+ const api = await bootModule({ controllers: [FeedController], services: services() });
278
+ const timersBefore = probe.timersRunning;
279
+ const reading = api.readEvents("/feed/live", { count: Infinity, timeoutMs: 4000 });
280
+ await waitFor(() => probe.timersRunning > timersBefore);
281
+
282
+ // Flux calls Symbol.asyncDispose on the entry's default export, which is the app.
283
+ await api.app[Symbol.asyncDispose]();
284
+
285
+ expect((await reading).ended).toBe(true);
286
+ expect(probe.timersRunning).toBe(timersBefore);
287
+ });
288
+ });
289
+
290
+ // Auth: a real shared-secret token service, no dev shortcuts.
291
+ const secretConfig = {
292
+ secret: "super-secret-key-that-is-long-enough-for-hs256",
293
+ issuer: "test-issuer",
294
+ audience: "test-audience",
295
+ };
296
+
297
+ class SecretConfigService implements IConfigurationService {
298
+ public get dataSources() {
299
+ return [];
300
+ }
301
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
302
+ if (type === AuthConfig)
303
+ return Object.assign(new AuthConfig(), secretConfig) as unknown as T;
304
+ return {} as T;
305
+ }
306
+ }
307
+
308
+ @Controller({ prefix: "/secure" })
309
+ class SecureFeedController {
310
+ @Authorize("read", "Article")
311
+ @Sse()
312
+ @Get("/articles")
313
+ public async *articles(): SseStream {
314
+ probe.started += 1;
315
+ yield { id: "1", data: "secret article" };
316
+ }
317
+
318
+ @AllowAnonymous()
319
+ @Sse()
320
+ @Get("/public")
321
+ public async *open(): SseStream {
322
+ yield "hello everyone";
323
+ }
324
+ }
325
+
326
+ describe("SSE: auth runs before any event is sent", () => {
327
+ const boot = () =>
328
+ bootModule({
329
+ controllers: [SecureFeedController],
330
+ services: (c) =>
331
+ c.registerSingleton(ConfigurationServiceToken, new SecretConfigService()),
332
+ configure: (builder) => builder.withAuth(),
333
+ });
334
+ const tokens = new TokenService(secretConfig);
335
+
336
+ it("answers 401 with no events when there is no token", async () => {
337
+ await using api = await boot();
338
+ probe.started = 0;
339
+
340
+ const read = await api.readEvents("/secure/articles");
341
+
342
+ expect(read.status).toBe(401);
343
+ expect(read.events).toEqual([]);
344
+ expect(read.headers.get("content-type")).not.toContain("text/event-stream");
345
+ expect(probe.started).toBe(0);
346
+ });
347
+
348
+ it("answers 403 with no events when the token lacks the permission", async () => {
349
+ await using api = await boot();
350
+ probe.started = 0;
351
+ const token = await tokens.sign({ sub: "alice", scope: "read:Comment" });
352
+
353
+ const read = await api.readEvents("/secure/articles", {
354
+ headers: { authorization: `Bearer ${token}` },
355
+ });
356
+
357
+ expect(read.status).toBe(403);
358
+ expect(read.events).toEqual([]);
359
+ expect(probe.started).toBe(0);
360
+ });
361
+
362
+ it("streams to a caller whose token allows it", async () => {
363
+ await using api = await boot();
364
+ const token = await tokens.sign({ sub: "alice", scope: "read:Article" });
365
+
366
+ const read = await api.readEvents("/secure/articles", {
367
+ headers: { authorization: `Bearer ${token}` },
368
+ });
369
+
370
+ expect(read.status).toBe(200);
371
+ expect(read.events).toEqual([{ id: "1", data: "secret article" }]);
372
+ });
373
+
374
+ it("honours @AllowAnonymous on a stream", async () => {
375
+ await using api = await boot();
376
+
377
+ const read = await api.readEvents("/secure/public");
378
+
379
+ expect(read.events).toEqual([{ data: "hello everyone" }]);
380
+ });
381
+ });
382
+
383
+ describe("SSE: OpenAPI", () => {
384
+ it("documents a stream route as text/event-stream, with the Last-Event-ID header", async () => {
385
+ await using api = await bootModule({
386
+ controllers: [FeedController],
387
+ services: services(),
388
+ configure: (builder) => builder.withOpenApi({ info: { title: "t", version: "1" } }),
389
+ });
390
+
391
+ const document = (await (
392
+ await fetch(`${api.baseUrl}/openapi.json`)
393
+ ).json()) as OpenApiDocument;
394
+
395
+ const stream = document.paths["/feed/items"].get;
396
+ expect(stream.responses["200"].content).toEqual({
397
+ "text/event-stream": { schema: { type: "string" } },
398
+ });
399
+ const resume = document.paths["/feed/resume"].get;
400
+ expect(resume.parameters).toContainEqual({
401
+ name: "Last-Event-ID",
402
+ in: "header",
403
+ schema: { type: "string" },
404
+ });
405
+ expect(document.paths["/feed/plain"].get.responses["200"].content).toBeUndefined();
406
+ });
407
+ });
408
+
409
+ function services() {
410
+ return (c: ContainerBuilder) => {
411
+ c.registerRequestScoped(RequestResource);
412
+ };
413
+ }
414
+
415
+ async function waitFor(condition: () => boolean, timeoutMs = 2000) {
416
+ const deadline = Date.now() + timeoutMs;
417
+ while (!condition()) {
418
+ if (Date.now() > deadline) throw new Error("condition not met in time");
419
+ await sleep(5);
420
+ }
421
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sseWire } from "../src/sse/sse.wire.js";
3
+
4
+ // Edges the HTTP tests cannot see: what the bytes look like for hostile or odd input.
5
+ describe("SSE wire format", () => {
6
+ it("cannot be made to inject a field through a line break in id or event", () => {
7
+ const frame = sseWire.formatMessage({
8
+ id: "1\ndata: injected",
9
+ event: "a\r\nretry: 1",
10
+ data: "x",
11
+ });
12
+
13
+ expect(frame).toBe("id: 1 data: injected\nevent: a retry: 1\ndata: x\n\n");
14
+ });
15
+
16
+ it("splits multi-line data into one data field per line, whatever the line ending", () => {
17
+ expect(sseWire.formatMessage("a\r\nb\rc\nd")).toBe(
18
+ "data: a\ndata: b\ndata: c\ndata: d\n\n",
19
+ );
20
+ });
21
+
22
+ it("writes non-string data as JSON and a bare string as-is", () => {
23
+ expect(sseWire.formatMessage({ data: { n: 1 } })).toBe('data: {"n":1}\n\n');
24
+ expect(sseWire.formatMessage("hi")).toBe("data: hi\n\n");
25
+ expect(sseWire.formatMessage(42)).toBe("data: 42\n\n");
26
+ });
27
+
28
+ it("writes retry as a whole number of milliseconds", () => {
29
+ expect(sseWire.formatMessage({ data: "x", retry: 1500.9 })).toBe(
30
+ "retry: 1500\ndata: x\n\n",
31
+ );
32
+ });
33
+ });
package/LICENSE DELETED
@@ -1,15 +0,0 @@
1
- ISC License
2
-
3
- Copyright (c) 2025-2026 Typesmiths
4
-
5
- Permission to use, copy, modify, and/or distribute this software for any
6
- purpose with or without fee is hereby granted, provided that the above
7
- copyright notice and this permission notice appear in all copies.
8
-
9
- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
- WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
- MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
- ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
- WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
- ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
- OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
@@ -1,166 +0,0 @@
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);
@@ -1,19 +0,0 @@
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
- }
@@ -1,17 +0,0 @@
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
- }