@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,604 @@
1
+ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2
+ import { request } from "node:http";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
6
+ import { setTimeout as sleep } from "node:timers/promises";
7
+ import {
8
+ ConfigurationServiceToken,
9
+ type IConfigurationService,
10
+ } from "@decohere/core/configuration";
11
+ import type { Constructable } from "@decohere/core/reflection";
12
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
13
+ import { AuthConfig } from "../src/auth/auth.config.js";
14
+ import "../src/auth/auth.extensions.js";
15
+ import {
16
+ Authorize,
17
+ Controller,
18
+ Get,
19
+ Header,
20
+ Signal,
21
+ Sse,
22
+ type SseStream,
23
+ } from "../src/index.js";
24
+ import { HttpError } from "../src/routing/anvil.types.js";
25
+ import { bootModule } from "../src/testing/index.js";
26
+ import type { Builder } from "../src/builder/builder.js";
27
+
28
+ // Static files are read by the real server from real disk (the suite's fs is an in-memory fake).
29
+ vi.unmock("fs");
30
+ vi.unmock("fs/promises");
31
+ vi.unmock("node:fs");
32
+ vi.unmock("node:fs/promises");
33
+
34
+ class SecretConfigService implements IConfigurationService {
35
+ public get dataSources() {
36
+ return [];
37
+ }
38
+ public async get<T extends object>(type: Constructable<T> | string): Promise<T> {
39
+ if (type === AuthConfig) {
40
+ return Object.assign(new AuthConfig(), {
41
+ secret: "super-secret-key-that-is-long-enough-for-hs256",
42
+ issuer: "test-issuer",
43
+ audience: "test-audience",
44
+ }) as unknown as T;
45
+ }
46
+ return {} as T;
47
+ }
48
+ }
49
+
50
+ const bigRows = Array.from({ length: 200 }, (_, i) => ({ id: i, name: `item number ${i}` }));
51
+ const smallBody = { ok: true };
52
+ const bigText = "The quick brown fox jumps over the lazy dog. ".repeat(100);
53
+
54
+ @Controller({ prefix: "/api" })
55
+ class ApiController {
56
+ @Get("/big")
57
+ public big() {
58
+ return bigRows;
59
+ }
60
+
61
+ @Get("/small")
62
+ public small() {
63
+ return smallBody;
64
+ }
65
+
66
+ @Header("content-type", "image/png")
67
+ @Get("/image")
68
+ public image() {
69
+ return bigText;
70
+ }
71
+
72
+ @Header("content-type", "text/plain")
73
+ @Header("content-encoding", "x-custom")
74
+ @Get("/encoded")
75
+ public encoded() {
76
+ return bigText;
77
+ }
78
+
79
+ @Header("content-type", "application/octet-stream")
80
+ @Get("/binary")
81
+ public binary() {
82
+ return bigText;
83
+ }
84
+
85
+ @Header("content-type", "text/plain")
86
+ @Get("/text")
87
+ public text() {
88
+ return bigText;
89
+ }
90
+
91
+ @Get("/boom")
92
+ public boom() {
93
+ // An error body above the threshold: the exception filter echoes the message.
94
+ throw new HttpError(bigText, 400);
95
+ }
96
+ }
97
+
98
+ @Controller({ prefix: "/api" })
99
+ class SecuredController {
100
+ @Authorize("read", "Article")
101
+ @Get("/secured")
102
+ public secured() {
103
+ return { secret: true };
104
+ }
105
+ }
106
+
107
+ @Controller({ prefix: "/feed" })
108
+ class FeedController {
109
+ /** Sends one event at once, then waits: a compressed stream would hold the first one back. */
110
+ @Sse({ heartbeatMs: 0 })
111
+ @Get("/live")
112
+ public async *live(@Signal() signal: AbortSignal): SseStream {
113
+ yield { id: "1", data: bigText };
114
+ await sleep(30_000, undefined, { signal }).catch(() => undefined);
115
+ yield { id: "2", data: "never reached in the test" };
116
+ }
117
+ }
118
+
119
+ type Wire = {
120
+ status: number;
121
+ headers: Record<string, string | string[] | undefined>;
122
+ /** The bytes as they crossed the wire, not decoded. */
123
+ raw: Buffer;
124
+ /** The body after undoing any `Content-Encoding`. */
125
+ body: string;
126
+ };
127
+
128
+ function decode(encoding: string | string[] | undefined, raw: Buffer): string {
129
+ if (raw.length === 0) return "";
130
+ if (encoding === "br") return brotliDecompressSync(raw).toString("utf8");
131
+ if (encoding === "gzip") return gunzipSync(raw).toString("utf8");
132
+ if (encoding === "deflate") return inflateSync(raw).toString("utf8");
133
+ return raw.toString("utf8");
134
+ }
135
+
136
+ /** A request that sends exactly the headers given and reads the body undecoded. Node's `fetch` negotiates and decodes by itself. */
137
+ function wire(
138
+ baseUrl: string,
139
+ pathname: string,
140
+ headers: Record<string, string> = {},
141
+ method = "GET",
142
+ ): Promise<Wire> {
143
+ const url = new URL(baseUrl);
144
+ return new Promise((resolve, reject) => {
145
+ const req = request(
146
+ { host: url.hostname, port: url.port, path: pathname, method, headers },
147
+ (res) => {
148
+ const chunks: Buffer[] = [];
149
+ res.on("data", (chunk: Buffer) => chunks.push(chunk));
150
+ res.on("end", () => {
151
+ const raw = Buffer.concat(chunks);
152
+ resolve({
153
+ status: res.statusCode ?? 0,
154
+ headers: res.headers,
155
+ raw,
156
+ body: decode(res.headers["content-encoding"], raw),
157
+ });
158
+ });
159
+ },
160
+ );
161
+ req.on("error", reject);
162
+ req.end();
163
+ });
164
+ }
165
+
166
+ function boot(
167
+ configure: (builder: Builder) => unknown = () => undefined,
168
+ extraControllers: Constructable[] = [],
169
+ ) {
170
+ return bootModule({
171
+ controllers: [ApiController, FeedController, ...extraControllers],
172
+ services: (c) => c.registerSingleton(ConfigurationServiceToken, new SecretConfigService()),
173
+ configure,
174
+ });
175
+ }
176
+
177
+ const bigJson = JSON.stringify(bigRows);
178
+
179
+ describe("response compression (#641)", () => {
180
+ describe("negotiation", () => {
181
+ it("sends brotli to a client that accepts it", async () => {
182
+ await using api = await boot();
183
+
184
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "br, gzip" });
185
+
186
+ expect(response.status).toBe(200);
187
+ expect(response.headers["content-encoding"]).toBe("br");
188
+ expect(response.raw.length).toBeLessThan(bigJson.length / 2);
189
+ expect(JSON.parse(response.body)).toEqual(bigRows);
190
+ });
191
+
192
+ it("sends gzip to a client that only accepts gzip", async () => {
193
+ await using api = await boot();
194
+
195
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "gzip" });
196
+
197
+ expect(response.headers["content-encoding"]).toBe("gzip");
198
+ expect(JSON.parse(response.body)).toEqual(bigRows);
199
+ });
200
+
201
+ it("does not offer deflate unless asked to", async () => {
202
+ await using api = await boot();
203
+
204
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "deflate" });
205
+
206
+ expect(response.headers["content-encoding"]).toBeUndefined();
207
+ expect(response.body).toBe(bigJson);
208
+ });
209
+
210
+ it("leaves the body alone when the client sends no Accept-Encoding", async () => {
211
+ await using api = await boot();
212
+
213
+ const response = await wire(api.baseUrl, "/api/big");
214
+
215
+ expect(response.headers["content-encoding"]).toBeUndefined();
216
+ expect(response.body).toBe(bigJson);
217
+ });
218
+
219
+ it("leaves the body alone when the client asks for identity", async () => {
220
+ await using api = await boot();
221
+
222
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "identity" });
223
+
224
+ expect(response.headers["content-encoding"]).toBeUndefined();
225
+ expect(response.body).toBe(bigJson);
226
+ });
227
+
228
+ it("is transparent to a normal client (fetch decodes it)", async () => {
229
+ await using api = await boot();
230
+
231
+ const response = await fetch(`${api.baseUrl}/api/big`);
232
+
233
+ expect(await response.json()).toEqual(bigRows);
234
+ });
235
+
236
+ it("compresses text, not only JSON", async () => {
237
+ await using api = await boot();
238
+
239
+ const response = await wire(api.baseUrl, "/api/text", { "accept-encoding": "gzip" });
240
+
241
+ expect(response.headers["content-encoding"]).toBe("gzip");
242
+ expect(response.body).toBe(bigText);
243
+ });
244
+ });
245
+
246
+ describe("what is not compressed", () => {
247
+ it("sends a body under the threshold as it is", async () => {
248
+ await using api = await boot();
249
+
250
+ const response = await wire(api.baseUrl, "/api/small", {
251
+ "accept-encoding": "br, gzip",
252
+ });
253
+
254
+ expect(response.headers["content-encoding"]).toBeUndefined();
255
+ expect(JSON.parse(response.body)).toEqual(smallBody);
256
+ });
257
+
258
+ it("does not compress already-compressed media", async () => {
259
+ await using api = await boot();
260
+
261
+ const response = await wire(api.baseUrl, "/api/image", {
262
+ "accept-encoding": "br, gzip",
263
+ });
264
+
265
+ expect(response.headers["content-type"]).toBe("image/png");
266
+ expect(response.headers["content-encoding"]).toBeUndefined();
267
+ expect(response.body).toBe(bigText);
268
+ });
269
+
270
+ it("passes through a response that already has a Content-Encoding", async () => {
271
+ await using api = await boot();
272
+
273
+ const response = await wire(api.baseUrl, "/api/encoded", {
274
+ "accept-encoding": "br, gzip",
275
+ });
276
+
277
+ expect(response.headers["content-encoding"]).toBe("x-custom");
278
+ expect(response.raw.toString("utf8")).toBe(bigText);
279
+ });
280
+ });
281
+
282
+ describe("headers", () => {
283
+ it("sets Vary: Accept-Encoding on a compressed response", async () => {
284
+ await using api = await boot();
285
+
286
+ const compressed = await wire(api.baseUrl, "/api/big", { "accept-encoding": "gzip" });
287
+
288
+ expect(compressed.headers.vary).toMatch(/accept-encoding/i);
289
+ });
290
+
291
+ it("does not add Vary to a response that is not compressed", async () => {
292
+ await using api = await boot();
293
+
294
+ const response = await wire(api.baseUrl, "/api/image", { "accept-encoding": "gzip" });
295
+
296
+ expect(response.headers.vary).toBeUndefined();
297
+ });
298
+
299
+ it("keeps the helmet, CORS and rate-limit headers on a compressed response", async () => {
300
+ await using api = await boot((builder) =>
301
+ builder.withCors({ origin: "https://app.example.com" }),
302
+ );
303
+
304
+ const response = await wire(api.baseUrl, "/api/big", {
305
+ "accept-encoding": "gzip",
306
+ origin: "https://app.example.com",
307
+ });
308
+
309
+ expect(response.headers["content-encoding"]).toBe("gzip");
310
+ expect(response.headers["x-content-type-options"]).toBe("nosniff");
311
+ expect(response.headers["access-control-allow-origin"]).toBe("https://app.example.com");
312
+ expect(response.headers["x-ratelimit-limit"]).toBeDefined();
313
+ expect(response.headers.vary).toMatch(/accept-encoding/i);
314
+ });
315
+ });
316
+
317
+ describe("HEAD and errors", () => {
318
+ it("answers HEAD with its headers and no body", async () => {
319
+ await using api = await boot();
320
+
321
+ const response = await wire(
322
+ api.baseUrl,
323
+ "/api/big",
324
+ { "accept-encoding": "gzip" },
325
+ "HEAD",
326
+ );
327
+
328
+ // Fastify builds a HEAD reply in a hook that runs before the compression plugin's, so
329
+ // a HEAD is answered uncompressed (with the plain Content-Length) and without Vary.
330
+ expect(response.status).toBe(200);
331
+ expect(response.headers["content-type"]).toContain("application/json");
332
+ expect(response.headers["content-encoding"]).toBeUndefined();
333
+ expect(response.raw.length).toBe(0);
334
+ });
335
+
336
+ it("still answers an unauthorised request with 401", async () => {
337
+ await using api = await boot((builder) => builder.withAuth(), [SecuredController]);
338
+
339
+ const response = await wire(api.baseUrl, "/api/secured", {
340
+ "accept-encoding": "br, gzip",
341
+ });
342
+
343
+ expect(response.status).toBe(401);
344
+ expect(JSON.parse(response.body)).toBeTypeOf("object");
345
+ });
346
+
347
+ it("compresses a large error body too", async () => {
348
+ await using api = await boot();
349
+
350
+ const response = await wire(api.baseUrl, "/api/boom", { "accept-encoding": "gzip" });
351
+
352
+ expect(response.status).toBe(400);
353
+ expect(response.headers["content-encoding"]).toBe("gzip");
354
+ expect(response.body.length).toBeGreaterThan(1024);
355
+ });
356
+
357
+ it("keeps the JSON 404 for an unknown route", async () => {
358
+ await using api = await boot();
359
+
360
+ const response = await wire(api.baseUrl, "/nope", { "accept-encoding": "gzip" });
361
+
362
+ expect(response.status).toBe(404);
363
+ expect(JSON.parse(response.body)).toBeTypeOf("object");
364
+ });
365
+ });
366
+
367
+ describe("server-sent events", () => {
368
+ it("does not compress a stream by default, and delivers the first event while the producer is still running", async () => {
369
+ await using api = await boot();
370
+
371
+ const started = Date.now();
372
+ const read = await api.readEvents("/feed/live", {
373
+ count: 1,
374
+ headers: { "accept-encoding": "br, gzip" },
375
+ timeoutMs: 3000,
376
+ });
377
+
378
+ expect(read.status).toBe(200);
379
+ expect(read.headers.get("content-type")).toContain("text/event-stream");
380
+ expect(read.headers.get("content-encoding")).toBeNull();
381
+ expect(read.events).toEqual([{ id: "1", data: bigText }]);
382
+ // The producer sleeps for 30 s after the first event. Compression would hold it back.
383
+ expect(Date.now() - started).toBeLessThan(2000);
384
+ });
385
+ });
386
+
387
+ describe("options", () => {
388
+ it("is off with withCompression(false)", async () => {
389
+ await using api = await boot((builder) => builder.withCompression(false));
390
+
391
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "br, gzip" });
392
+
393
+ expect(response.headers["content-encoding"]).toBeUndefined();
394
+ expect(response.headers.vary).toBeUndefined();
395
+ expect(response.body).toBe(bigJson);
396
+ });
397
+
398
+ it("compresses by default with withCompression() and no options", async () => {
399
+ await using api = await boot((builder) => builder.withCompression());
400
+
401
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "gzip" });
402
+
403
+ expect(response.headers["content-encoding"]).toBe("gzip");
404
+ });
405
+
406
+ it("honours a custom threshold", async () => {
407
+ await using api = await boot((builder) => builder.withCompression({ threshold: 5 }));
408
+
409
+ const small = await wire(api.baseUrl, "/api/small", { "accept-encoding": "gzip" });
410
+ expect(small.headers["content-encoding"]).toBe("gzip");
411
+
412
+ await using high = await boot((builder) =>
413
+ builder.withCompression({ threshold: 1_000_000 }),
414
+ );
415
+ const big = await wire(high.baseUrl, "/api/big", { "accept-encoding": "gzip" });
416
+ expect(big.headers["content-encoding"]).toBeUndefined();
417
+ });
418
+
419
+ it("honours custom encodings and their order", async () => {
420
+ await using gzipOnly = await boot((builder) =>
421
+ builder.withCompression({ encodings: ["gzip"] }),
422
+ );
423
+ const onlyGzip = await wire(gzipOnly.baseUrl, "/api/big", {
424
+ "accept-encoding": "br, gzip",
425
+ });
426
+ expect(onlyGzip.headers["content-encoding"]).toBe("gzip");
427
+
428
+ await using withDeflate = await boot((builder) =>
429
+ builder.withCompression({ encodings: ["deflate", "gzip"] }),
430
+ );
431
+ const deflate = await wire(withDeflate.baseUrl, "/api/big", {
432
+ "accept-encoding": "gzip, deflate",
433
+ });
434
+ expect(deflate.headers["content-encoding"]).toBe("deflate");
435
+ expect(JSON.parse(deflate.body)).toEqual(bigRows);
436
+ });
437
+
438
+ it("adds content types with types (a RegExp)", async () => {
439
+ await using api = await boot((builder) =>
440
+ builder.withCompression({ types: /^image\/png/ }),
441
+ );
442
+
443
+ const image = await wire(api.baseUrl, "/api/image", { "accept-encoding": "gzip" });
444
+
445
+ expect(image.headers["content-encoding"]).toBe("gzip");
446
+ });
447
+
448
+ it("accepts a function for types", async () => {
449
+ await using api = await boot((builder) =>
450
+ builder.withCompression({ types: (type) => type.startsWith("image/png") }),
451
+ );
452
+
453
+ const image = await wire(api.baseUrl, "/api/image", { "accept-encoding": "gzip" });
454
+
455
+ expect(image.headers["content-encoding"]).toBe("gzip");
456
+ });
457
+
458
+ it("compresses application/octet-stream by default (the plugin's default)", async () => {
459
+ await using api = await boot();
460
+
461
+ const response = await wire(api.baseUrl, "/api/binary", { "accept-encoding": "gzip" });
462
+
463
+ expect(response.headers["content-encoding"]).toBe("gzip");
464
+ });
465
+
466
+ it("passes providerOptions to the plugin, overriding the defaults", async () => {
467
+ await using api = await boot((builder) =>
468
+ builder.withCompression({ providerOptions: { threshold: 1_000_000 } }),
469
+ );
470
+
471
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "gzip" });
472
+
473
+ expect(response.headers["content-encoding"]).toBeUndefined();
474
+ });
475
+
476
+ it("lets providerOptions reach other plugin options (onUnsupportedEncoding)", async () => {
477
+ await using api = await boot((builder) =>
478
+ builder.withCompression({
479
+ providerOptions: {
480
+ onUnsupportedEncoding: () => JSON.stringify({ message: "unsupported" }),
481
+ },
482
+ }),
483
+ );
484
+
485
+ const response = await wire(api.baseUrl, "/api/big", { "accept-encoding": "compress" });
486
+
487
+ expect(JSON.parse(response.body)).toEqual({ message: "unsupported" });
488
+ });
489
+ });
490
+
491
+ describe("files served by withStatic", () => {
492
+ let site: string;
493
+ const script = `${"export const answer = 42; // padding to pass the threshold\n".repeat(60)}`;
494
+
495
+ beforeAll(async () => {
496
+ site = await mkdtemp(path.join(tmpdir(), "http-compress-"));
497
+ await mkdir(path.join(site, "assets"), { recursive: true });
498
+ await writeFile(
499
+ path.join(site, "index.html"),
500
+ `<!doctype html><title>app</title>${bigText}`,
501
+ );
502
+ await writeFile(path.join(site, "assets/index-DiwrgTda.js"), script);
503
+ await writeFile(path.join(site, "assets/pic.png"), bigText);
504
+ await writeFile(path.join(site, "assets/tiny.css"), "body{}");
505
+ });
506
+
507
+ afterAll(async () => {
508
+ await rm(site, { recursive: true, force: true });
509
+ });
510
+
511
+ const bootUi = () => boot((builder) => builder.withStatic("/ui/", site));
512
+
513
+ it("compresses a file on the fly and keeps its caching headers", async () => {
514
+ await using api = await bootUi();
515
+
516
+ const asset = await wire(api.baseUrl, "/ui/assets/index-DiwrgTda.js", {
517
+ "accept-encoding": "br, gzip",
518
+ });
519
+ expect(asset.status).toBe(200);
520
+ expect(asset.headers["content-encoding"]).toBe("br");
521
+ expect(asset.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
522
+ expect(asset.headers.etag).toBeDefined();
523
+ expect(asset.headers.vary).toMatch(/accept-encoding/i);
524
+ expect(asset.body).toBe(script);
525
+
526
+ const index = await wire(api.baseUrl, "/ui/", { "accept-encoding": "gzip" });
527
+ expect(index.headers["content-encoding"]).toBe("gzip");
528
+ expect(index.headers["cache-control"]).toBe("no-cache");
529
+ expect(index.body).toContain("<title>app</title>");
530
+ });
531
+
532
+ it("compresses the SPA fallback like index.html", async () => {
533
+ await using api = await bootUi();
534
+
535
+ const response = await wire(api.baseUrl, "/ui/portfolio/42", {
536
+ "accept-encoding": "gzip",
537
+ accept: "text/html",
538
+ });
539
+
540
+ expect(response.status).toBe(200);
541
+ expect(response.headers["content-encoding"]).toBe("gzip");
542
+ expect(response.headers["cache-control"]).toBe("no-cache");
543
+ });
544
+
545
+ it("answers a conditional request with a 304 and no body", async () => {
546
+ await using api = await bootUi();
547
+ const first = await wire(api.baseUrl, "/ui/assets/index-DiwrgTda.js", {
548
+ "accept-encoding": "gzip",
549
+ });
550
+ const etag = String(first.headers.etag);
551
+
552
+ const second = await wire(api.baseUrl, "/ui/assets/index-DiwrgTda.js", {
553
+ "accept-encoding": "gzip",
554
+ "if-none-match": etag,
555
+ });
556
+
557
+ expect(second.status).toBe(304);
558
+ expect(second.raw.length).toBe(0);
559
+ expect(second.headers.etag).toBe(first.headers.etag);
560
+ expect(second.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
561
+ });
562
+
563
+ it("answers HEAD for a file with no body", async () => {
564
+ await using api = await bootUi();
565
+
566
+ const response = await wire(
567
+ api.baseUrl,
568
+ "/ui/assets/index-DiwrgTda.js",
569
+ { "accept-encoding": "gzip" },
570
+ "HEAD",
571
+ );
572
+
573
+ expect(response.status).toBe(200);
574
+ expect(response.raw.length).toBe(0);
575
+ expect(response.headers["cache-control"]).toBe("public, max-age=31536000, immutable");
576
+ });
577
+
578
+ it("compresses a small text file (the plugin skips its threshold for streams) and leaves a binary file alone", async () => {
579
+ await using api = await bootUi();
580
+
581
+ const tiny = await wire(api.baseUrl, "/ui/assets/tiny.css", {
582
+ "accept-encoding": "gzip",
583
+ });
584
+ const png = await wire(api.baseUrl, "/ui/assets/pic.png", { "accept-encoding": "gzip" });
585
+
586
+ expect(tiny.headers["content-encoding"]).toBe("gzip");
587
+ expect(tiny.body).toBe("body{}");
588
+ expect(png.headers["content-encoding"]).toBeUndefined();
589
+ });
590
+
591
+ it("sends files as they are with withCompression(false)", async () => {
592
+ await using api = await boot((builder) =>
593
+ builder.withCompression(false).withStatic("/ui/", site),
594
+ );
595
+
596
+ const response = await wire(api.baseUrl, "/ui/assets/index-DiwrgTda.js", {
597
+ "accept-encoding": "gzip",
598
+ });
599
+
600
+ expect(response.headers["content-encoding"]).toBeUndefined();
601
+ expect(response.body).toBe(script);
602
+ });
603
+ });
604
+ });
@@ -0,0 +1,71 @@
1
+ import axios from "axios";
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 "../src/http/http.extensions.js";
6
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
7
+ import { Controller, Field, Get, Post, Query, RequestParts } from "../src/routing/index.js";
8
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
9
+
10
+ const ISO = "2024-01-15T00:00:00.000Z";
11
+
12
+ class EventBody {
13
+ @Field(RequestParts.body, "when")
14
+ public when!: Date;
15
+ }
16
+
17
+ @Controller({ prefix: "/events" })
18
+ class EventController {
19
+ @Get()
20
+ public get(@Query("when") when: Date) {
21
+ return { when: when instanceof Date ? when.toISOString() : when };
22
+ }
23
+
24
+ @Post()
25
+ public create(body: EventBody) {
26
+ return {
27
+ when: body.when instanceof Date ? body.when.toISOString() : body.when,
28
+ };
29
+ }
30
+ }
31
+
32
+ describe("DatePipe over HTTP", () => {
33
+ let app: App;
34
+
35
+ afterEach(async () => {
36
+ await app?.dispose();
37
+ });
38
+
39
+ async function start() {
40
+ app = await Builder.create()
41
+ .withHttpProvider(FastifyProvider)
42
+ .withControllers(EventController)
43
+ .build();
44
+ axios.defaults.baseURL = await listenEphemeral(app);
45
+ }
46
+
47
+ it("binds an ISO date query to a Date", async () => {
48
+ await start();
49
+ const res = await axios.get("/events", { params: { when: ISO } });
50
+ expect(res.status).toBe(200);
51
+ expect(res.data).toEqual({ when: ISO });
52
+ });
53
+
54
+ it("rejects an empty date query with 400", async () => {
55
+ await start();
56
+ const res = await axios.get("/events?when=", { validateStatus: () => true });
57
+ expect(res.status).toBe(400);
58
+ expect(res.data).toMatchObject({ statusCode: 400, error: "Bad Request" });
59
+ });
60
+
61
+ it("binds a JSON body date field to a Date", async () => {
62
+ await start();
63
+ const res = await axios.post(
64
+ "/events",
65
+ { when: ISO },
66
+ { headers: { "Content-Type": "application/json" } },
67
+ );
68
+ expect(res.status).toBe(200);
69
+ expect(res.data).toEqual({ when: ISO });
70
+ });
71
+ });