@furystack/rest-service 13.0.0 → 14.1.0

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 (311) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +47 -28
  3. package/esm/actions/error-action.d.ts +7 -2
  4. package/esm/actions/error-action.d.ts.map +1 -1
  5. package/esm/actions/error-action.js +7 -2
  6. package/esm/actions/error-action.js.map +1 -1
  7. package/esm/actions/get-current-user.d.ts +1 -4
  8. package/esm/actions/get-current-user.d.ts.map +1 -1
  9. package/esm/actions/get-current-user.js +1 -4
  10. package/esm/actions/get-current-user.js.map +1 -1
  11. package/esm/actions/get-current-user.spec.js +10 -6
  12. package/esm/actions/get-current-user.spec.js.map +1 -1
  13. package/esm/actions/is-authenticated.d.ts +3 -4
  14. package/esm/actions/is-authenticated.d.ts.map +1 -1
  15. package/esm/actions/is-authenticated.js +3 -4
  16. package/esm/actions/is-authenticated.js.map +1 -1
  17. package/esm/actions/is-authenticated.spec.js +10 -6
  18. package/esm/actions/is-authenticated.spec.js.map +1 -1
  19. package/esm/actions/logout-action.spec.js +11 -9
  20. package/esm/actions/logout-action.spec.js.map +1 -1
  21. package/esm/actions/logout.d.ts +2 -6
  22. package/esm/actions/logout.d.ts.map +1 -1
  23. package/esm/actions/logout.js +3 -7
  24. package/esm/actions/logout.js.map +1 -1
  25. package/esm/actions/not-found-action.d.ts +1 -3
  26. package/esm/actions/not-found-action.d.ts.map +1 -1
  27. package/esm/actions/not-found-action.js +1 -3
  28. package/esm/actions/not-found-action.js.map +1 -1
  29. package/esm/actions/password-login-action.d.ts +5 -12
  30. package/esm/actions/password-login-action.d.ts.map +1 -1
  31. package/esm/actions/password-login-action.js +6 -13
  32. package/esm/actions/password-login-action.js.map +1 -1
  33. package/esm/actions/password-login-action.spec.js +23 -28
  34. package/esm/actions/password-login-action.spec.js.map +1 -1
  35. package/esm/authenticate.d.ts +5 -11
  36. package/esm/authenticate.d.ts.map +1 -1
  37. package/esm/authenticate.js +6 -12
  38. package/esm/authenticate.js.map +1 -1
  39. package/esm/authenticate.spec.js +41 -33
  40. package/esm/authenticate.spec.js.map +1 -1
  41. package/esm/authentication-providers/authentication-provider.d.ts +17 -0
  42. package/esm/authentication-providers/authentication-provider.d.ts.map +1 -1
  43. package/esm/authentication-providers/basic-auth-provider.d.ts +7 -0
  44. package/esm/authentication-providers/basic-auth-provider.d.ts.map +1 -1
  45. package/esm/authentication-providers/basic-auth-provider.js +7 -0
  46. package/esm/authentication-providers/basic-auth-provider.js.map +1 -1
  47. package/esm/authentication-providers/cookie-auth-provider.d.ts.map +1 -1
  48. package/esm/authentication-providers/cookie-auth-provider.js +8 -0
  49. package/esm/authentication-providers/cookie-auth-provider.js.map +1 -1
  50. package/esm/authorize.d.ts +5 -17
  51. package/esm/authorize.d.ts.map +1 -1
  52. package/esm/authorize.js +5 -17
  53. package/esm/authorize.js.map +1 -1
  54. package/esm/authorize.spec.js +34 -29
  55. package/esm/authorize.spec.js.map +1 -1
  56. package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
  57. package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
  58. package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
  59. package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
  60. package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
  61. package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
  62. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
  63. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
  64. package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
  65. package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
  66. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
  67. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
  68. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
  69. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
  70. package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
  71. package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
  72. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
  73. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
  74. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -1
  75. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
  76. package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -1
  77. package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
  78. package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
  79. package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
  80. package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
  81. package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
  82. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  83. package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
  84. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  85. package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
  86. package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
  87. package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
  88. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  89. package/esm/endpoint-generators/create-post-endpoint.js +8 -13
  90. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  91. package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
  92. package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
  93. package/esm/endpoint-generators/utils.d.ts +17 -2
  94. package/esm/endpoint-generators/utils.d.ts.map +1 -1
  95. package/esm/endpoint-generators/utils.js +33 -20
  96. package/esm/endpoint-generators/utils.js.map +1 -1
  97. package/esm/get-schema-from-api.d.ts +1 -1
  98. package/esm/get-schema-from-api.d.ts.map +1 -1
  99. package/esm/get-schema-from-api.js +1 -0
  100. package/esm/get-schema-from-api.js.map +1 -1
  101. package/esm/get-schema-from-api.spec.js.map +1 -1
  102. package/esm/helpers.d.ts +54 -88
  103. package/esm/helpers.d.ts.map +1 -1
  104. package/esm/helpers.js +90 -92
  105. package/esm/helpers.js.map +1 -1
  106. package/esm/helpers.spec.d.ts +2 -1
  107. package/esm/helpers.spec.d.ts.map +1 -1
  108. package/esm/helpers.spec.js +41 -38
  109. package/esm/helpers.spec.js.map +1 -1
  110. package/esm/http-authentication-settings.d.ts +56 -12
  111. package/esm/http-authentication-settings.d.ts.map +1 -1
  112. package/esm/http-authentication-settings.js +27 -29
  113. package/esm/http-authentication-settings.js.map +1 -1
  114. package/esm/http-server-pool.d.ts +64 -0
  115. package/esm/http-server-pool.d.ts.map +1 -0
  116. package/esm/http-server-pool.js +95 -0
  117. package/esm/http-server-pool.js.map +1 -0
  118. package/esm/http-server-pool.spec.d.ts +2 -0
  119. package/esm/http-server-pool.spec.d.ts.map +1 -0
  120. package/esm/http-server-pool.spec.js +193 -0
  121. package/esm/http-server-pool.spec.js.map +1 -0
  122. package/esm/http-user-context.d.ts +34 -44
  123. package/esm/http-user-context.d.ts.map +1 -1
  124. package/esm/http-user-context.js +75 -95
  125. package/esm/http-user-context.js.map +1 -1
  126. package/esm/http-user-context.spec.d.ts +8 -2
  127. package/esm/http-user-context.spec.d.ts.map +1 -1
  128. package/esm/http-user-context.spec.js +226 -332
  129. package/esm/http-user-context.spec.js.map +1 -1
  130. package/esm/identity-cache-keys.d.ts +28 -0
  131. package/esm/identity-cache-keys.d.ts.map +1 -0
  132. package/esm/identity-cache-keys.js +13 -0
  133. package/esm/identity-cache-keys.js.map +1 -0
  134. package/esm/identity-cache-keys.spec.d.ts +2 -0
  135. package/esm/identity-cache-keys.spec.d.ts.map +1 -0
  136. package/esm/identity-cache-keys.spec.js +21 -0
  137. package/esm/identity-cache-keys.spec.js.map +1 -0
  138. package/esm/identity-event-bus.d.ts +102 -0
  139. package/esm/identity-event-bus.d.ts.map +1 -0
  140. package/esm/identity-event-bus.js +151 -0
  141. package/esm/identity-event-bus.js.map +1 -0
  142. package/esm/identity-event-bus.spec.d.ts +2 -0
  143. package/esm/identity-event-bus.spec.d.ts.map +1 -0
  144. package/esm/identity-event-bus.spec.js +392 -0
  145. package/esm/identity-event-bus.spec.js.map +1 -0
  146. package/esm/index.d.ts +9 -4
  147. package/esm/index.d.ts.map +1 -1
  148. package/esm/index.js +9 -4
  149. package/esm/index.js.map +1 -1
  150. package/esm/login-response-strategy.d.ts +11 -10
  151. package/esm/login-response-strategy.d.ts.map +1 -1
  152. package/esm/login-response-strategy.js +7 -6
  153. package/esm/login-response-strategy.js.map +1 -1
  154. package/esm/login-response-strategy.spec.js +25 -29
  155. package/esm/login-response-strategy.spec.js.map +1 -1
  156. package/esm/openapi/generate-openapi-document.spec.js.map +1 -1
  157. package/esm/proxy-manager.spec.js +152 -176
  158. package/esm/proxy-manager.spec.js.map +1 -1
  159. package/esm/proxy-runtime.d.ts +27 -0
  160. package/esm/proxy-runtime.d.ts.map +1 -0
  161. package/esm/proxy-runtime.js +45 -0
  162. package/esm/proxy-runtime.js.map +1 -0
  163. package/esm/proxy-runtime.spec.d.ts +2 -0
  164. package/esm/proxy-runtime.spec.d.ts.map +1 -0
  165. package/esm/proxy-runtime.spec.js +39 -0
  166. package/esm/proxy-runtime.spec.js.map +1 -0
  167. package/esm/request-action-implementation.d.ts +25 -0
  168. package/esm/request-action-implementation.d.ts.map +1 -1
  169. package/esm/request-action-implementation.js +9 -0
  170. package/esm/request-action-implementation.js.map +1 -1
  171. package/esm/rest-api-runtime.d.ts +76 -0
  172. package/esm/rest-api-runtime.d.ts.map +1 -0
  173. package/esm/rest-api-runtime.js +131 -0
  174. package/esm/rest-api-runtime.js.map +1 -0
  175. package/esm/rest-api-runtime.spec.d.ts +2 -0
  176. package/esm/rest-api-runtime.spec.d.ts.map +1 -0
  177. package/esm/rest-api-runtime.spec.js +58 -0
  178. package/esm/rest-api-runtime.spec.js.map +1 -0
  179. package/esm/rest-service.integration.spec.d.ts +1 -1
  180. package/esm/rest-service.integration.spec.d.ts.map +1 -1
  181. package/esm/rest-service.integration.spec.js +57 -80
  182. package/esm/rest-service.integration.spec.js.map +1 -1
  183. package/esm/rest.integration.test.js +26 -84
  184. package/esm/rest.integration.test.js.map +1 -1
  185. package/esm/server-telemetry.d.ts +49 -0
  186. package/esm/server-telemetry.d.ts.map +1 -0
  187. package/esm/server-telemetry.js +25 -0
  188. package/esm/server-telemetry.js.map +1 -0
  189. package/esm/server-telemetry.spec.d.ts +2 -0
  190. package/esm/server-telemetry.spec.d.ts.map +1 -0
  191. package/esm/server-telemetry.spec.js +52 -0
  192. package/esm/server-telemetry.spec.js.map +1 -0
  193. package/esm/static-server-manager.spec.js +60 -125
  194. package/esm/static-server-manager.spec.js.map +1 -1
  195. package/esm/static-site-runtime.d.ts +21 -0
  196. package/esm/static-site-runtime.d.ts.map +1 -0
  197. package/esm/static-site-runtime.js +48 -0
  198. package/esm/static-site-runtime.js.map +1 -0
  199. package/esm/static-site-runtime.spec.d.ts +2 -0
  200. package/esm/static-site-runtime.spec.d.ts.map +1 -0
  201. package/esm/static-site-runtime.spec.js +30 -0
  202. package/esm/static-site-runtime.spec.js.map +1 -0
  203. package/esm/user-resolution-cache.d.ts +65 -0
  204. package/esm/user-resolution-cache.d.ts.map +1 -0
  205. package/esm/user-resolution-cache.js +101 -0
  206. package/esm/user-resolution-cache.js.map +1 -0
  207. package/esm/user-resolution-cache.spec.d.ts +2 -0
  208. package/esm/user-resolution-cache.spec.d.ts.map +1 -0
  209. package/esm/user-resolution-cache.spec.js +220 -0
  210. package/esm/user-resolution-cache.spec.js.map +1 -0
  211. package/esm/user-store.d.ts +39 -0
  212. package/esm/user-store.d.ts.map +1 -0
  213. package/esm/user-store.js +62 -0
  214. package/esm/user-store.js.map +1 -0
  215. package/esm/user-store.spec.d.ts +2 -0
  216. package/esm/user-store.spec.d.ts.map +1 -0
  217. package/esm/user-store.spec.js +72 -0
  218. package/esm/user-store.spec.js.map +1 -0
  219. package/esm/validate.d.ts +13 -12
  220. package/esm/validate.d.ts.map +1 -1
  221. package/esm/validate.integration.spec.js +27 -28
  222. package/esm/validate.integration.spec.js.map +1 -1
  223. package/esm/validate.js +12 -0
  224. package/esm/validate.js.map +1 -1
  225. package/package.json +14 -12
  226. package/src/actions/error-action.ts +7 -3
  227. package/src/actions/get-current-user.spec.ts +11 -10
  228. package/src/actions/get-current-user.ts +1 -4
  229. package/src/actions/is-authenticated.spec.ts +12 -7
  230. package/src/actions/is-authenticated.ts +3 -4
  231. package/src/actions/logout-action.spec.ts +12 -12
  232. package/src/actions/logout.ts +3 -7
  233. package/src/actions/not-found-action.ts +1 -3
  234. package/src/actions/password-login-action.spec.ts +30 -30
  235. package/src/actions/password-login-action.ts +6 -13
  236. package/src/authenticate.spec.ts +45 -51
  237. package/src/authenticate.ts +6 -12
  238. package/src/authentication-providers/authentication-provider.ts +17 -0
  239. package/src/authentication-providers/basic-auth-provider.ts +7 -0
  240. package/src/authentication-providers/cookie-auth-provider.ts +8 -0
  241. package/src/authorize.spec.ts +37 -39
  242. package/src/authorize.ts +6 -17
  243. package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
  244. package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
  245. package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
  246. package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
  247. package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
  248. package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
  249. package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -2
  250. package/src/endpoint-generators/create-get-schema-action.ts +1 -1
  251. package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
  252. package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
  253. package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
  254. package/src/endpoint-generators/create-post-endpoint.ts +13 -23
  255. package/src/endpoint-generators/utils.ts +36 -28
  256. package/src/get-schema-from-api.spec.ts +1 -1
  257. package/src/get-schema-from-api.ts +2 -1
  258. package/src/helpers.spec.ts +51 -41
  259. package/src/helpers.ts +140 -109
  260. package/src/http-authentication-settings.ts +78 -24
  261. package/src/http-server-pool.spec.ts +220 -0
  262. package/src/http-server-pool.ts +169 -0
  263. package/src/http-user-context.spec.ts +246 -378
  264. package/src/http-user-context.ts +122 -92
  265. package/src/identity-cache-keys.spec.ts +24 -0
  266. package/src/identity-cache-keys.ts +31 -0
  267. package/src/identity-event-bus.spec.ts +285 -0
  268. package/src/identity-event-bus.ts +230 -0
  269. package/src/index.ts +9 -4
  270. package/src/login-response-strategy.spec.ts +32 -31
  271. package/src/login-response-strategy.ts +14 -12
  272. package/src/openapi/generate-openapi-document.spec.ts +2 -2
  273. package/src/proxy-manager.spec.ts +158 -188
  274. package/src/proxy-runtime.spec.ts +50 -0
  275. package/src/proxy-runtime.ts +75 -0
  276. package/src/request-action-implementation.ts +25 -0
  277. package/src/rest-api-runtime.spec.ts +77 -0
  278. package/src/rest-api-runtime.ts +204 -0
  279. package/src/rest-service.integration.spec.ts +65 -85
  280. package/src/rest.integration.test.ts +27 -87
  281. package/src/server-telemetry.spec.ts +56 -0
  282. package/src/server-telemetry.ts +43 -0
  283. package/src/static-server-manager.spec.ts +63 -151
  284. package/src/static-site-runtime.spec.ts +39 -0
  285. package/src/static-site-runtime.ts +75 -0
  286. package/src/user-resolution-cache.spec.ts +241 -0
  287. package/src/user-resolution-cache.ts +164 -0
  288. package/src/user-store.spec.ts +86 -0
  289. package/src/user-store.ts +69 -0
  290. package/src/validate.integration.spec.ts +40 -35
  291. package/src/validate.ts +14 -12
  292. package/esm/api-manager.d.ts +0 -104
  293. package/esm/api-manager.d.ts.map +0 -1
  294. package/esm/api-manager.js +0 -179
  295. package/esm/api-manager.js.map +0 -1
  296. package/esm/proxy-manager.d.ts +0 -53
  297. package/esm/proxy-manager.d.ts.map +0 -1
  298. package/esm/proxy-manager.js +0 -84
  299. package/esm/proxy-manager.js.map +0 -1
  300. package/esm/server-manager.d.ts +0 -48
  301. package/esm/server-manager.d.ts.map +0 -1
  302. package/esm/server-manager.js +0 -100
  303. package/esm/server-manager.js.map +0 -1
  304. package/esm/static-server-manager.d.ts +0 -19
  305. package/esm/static-server-manager.d.ts.map +0 -1
  306. package/esm/static-server-manager.js +0 -65
  307. package/esm/static-server-manager.js.map +0 -1
  308. package/src/api-manager.ts +0 -304
  309. package/src/proxy-manager.ts +0 -102
  310. package/src/server-manager.ts +0 -135
  311. package/src/static-server-manager.ts +0 -90
@@ -0,0 +1,50 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
2
+ import { buildProxyServerApi, type ProxyOptions } from './proxy-runtime.js'
3
+ import { ServerTelemetry } from './server-telemetry.js'
4
+
5
+ const baseOptions: ProxyOptions = {
6
+ sourceBaseUrl: '/api',
7
+ sourcePort: 0,
8
+ targetBaseUrl: 'http://upstream.invalid:8080',
9
+ }
10
+
11
+ describe('buildProxyServerApi', () => {
12
+ let telemetry: ServerTelemetry
13
+
14
+ beforeEach(() => {
15
+ telemetry = new ServerTelemetry()
16
+ })
17
+ afterEach(() => {
18
+ telemetry[Symbol.dispose]()
19
+ })
20
+
21
+ it('returns a ServerApi whose shouldExec matches URLs under the sourceBaseUrl', () => {
22
+ const api = buildProxyServerApi(baseOptions, telemetry)
23
+ expect(api.shouldExec({ req: { url: '/api/items', method: 'GET' } as never, res: {} as never })).toBe(true)
24
+ expect(api.shouldExec({ req: { url: '/other', method: 'GET' } as never, res: {} as never })).toBe(false)
25
+ })
26
+
27
+ it('shouldExec rejects requests missing a URL', () => {
28
+ const api = buildProxyServerApi(baseOptions, telemetry)
29
+ expect(api.shouldExec({ req: { method: 'GET' } as never, res: {} as never })).toBe(false)
30
+ })
31
+
32
+ it('does not attach onUpgrade unless websockets are explicitly enabled', () => {
33
+ const apiWithoutWs = buildProxyServerApi(baseOptions, telemetry)
34
+ const apiWithWs = buildProxyServerApi({ ...baseOptions, enableWebsockets: true }, telemetry)
35
+ expect(apiWithoutWs.onUpgrade).toBeUndefined()
36
+ expect(typeof apiWithWs.onUpgrade).toBe('function')
37
+ })
38
+
39
+ it('throws on an invalid targetBaseUrl', () => {
40
+ expect(() => buildProxyServerApi({ ...baseOptions, targetBaseUrl: 'not-a-url' }, telemetry)).toThrow(
41
+ /Invalid targetBaseUrl/,
42
+ )
43
+ })
44
+
45
+ it('throws on non-HTTP(S) targets', () => {
46
+ expect(() =>
47
+ buildProxyServerApi({ ...baseOptions, targetBaseUrl: 'ftp://upstream.invalid:21' }, telemetry),
48
+ ).toThrow(/protocol/i)
49
+ })
50
+ })
@@ -0,0 +1,75 @@
1
+ import { PathHelper } from '@furystack/utils'
2
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
3
+ import type { Duplex } from 'stream'
4
+ import { HttpProxyHandler } from './http-proxy-handler.js'
5
+ import type { ServerApi } from './http-server-pool.js'
6
+ import { PathProcessor } from './path-processor.js'
7
+ import type { ServerTelemetry } from './server-telemetry.js'
8
+ import { WebSocketProxyHandler } from './websocket-proxy-handler.js'
9
+
10
+ /**
11
+ * Options accepted by {@link buildProxyServerApi} and {@link useProxy}.
12
+ * Mirrors the original `ProxyOptions` shape from the deleted
13
+ * `ProxyManager`; see that history for field-by-field documentation.
14
+ */
15
+ export interface ProxyOptions {
16
+ sourceBaseUrl: string
17
+ targetBaseUrl: string
18
+ pathRewrite?: (sourcePath: string) => string
19
+ sourceHostName?: string
20
+ sourcePort: number
21
+ headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
22
+ cookies?: (originalCookies: string[]) => string[]
23
+ responseCookies?: (responseCookies: string[]) => string[]
24
+ timeout?: number
25
+ enableWebsockets?: boolean
26
+ }
27
+
28
+ const createRequestHandler = (options: ProxyOptions, telemetry: ServerTelemetry) => {
29
+ const handler = new HttpProxyHandler({
30
+ sourceBaseUrl: options.sourceBaseUrl,
31
+ targetBaseUrl: options.targetBaseUrl,
32
+ pathRewrite: options.pathRewrite,
33
+ headers: options.headers,
34
+ cookies: options.cookies,
35
+ responseCookies: options.responseCookies,
36
+ timeout: options.timeout,
37
+ onError: (error) => telemetry.emit('onProxyFailed', error),
38
+ })
39
+ return async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => handler.handle(req, res)
40
+ }
41
+
42
+ const createUpgradeHandler = (options: ProxyOptions, telemetry: ServerTelemetry) => {
43
+ const handler = new WebSocketProxyHandler({
44
+ sourceBaseUrl: options.sourceBaseUrl,
45
+ targetBaseUrl: options.targetBaseUrl,
46
+ pathRewrite: options.pathRewrite,
47
+ headers: options.headers,
48
+ timeout: options.timeout,
49
+ onError: (error) => telemetry.emit('onWebSocketProxyFailed', error),
50
+ })
51
+ return async ({ req, socket, head }: { req: IncomingMessage; socket: Duplex; head: Buffer }) =>
52
+ handler.handle(req, socket, head)
53
+ }
54
+
55
+ const shouldExecProxy =
56
+ (sourceBaseUrl: string) =>
57
+ ({ req }: { req: Pick<IncomingMessage, 'url' | 'method'> }): boolean =>
58
+ req.url ? PathHelper.matchesBaseUrl(req.url, sourceBaseUrl) : false
59
+
60
+ /**
61
+ * Validates the proxy configuration and returns a {@link ServerApi} that
62
+ * forwards HTTP (and optionally WebSocket) traffic from
63
+ * `options.sourceBaseUrl` to `options.targetBaseUrl`.
64
+ */
65
+ export const buildProxyServerApi = (options: ProxyOptions, telemetry: ServerTelemetry): ServerApi => {
66
+ const pathProcessor = new PathProcessor()
67
+ const targetUrl = pathProcessor.validateUrl(options.targetBaseUrl, 'targetBaseUrl')
68
+ pathProcessor.validateHttpProtocol(targetUrl)
69
+
70
+ return {
71
+ shouldExec: shouldExecProxy(options.sourceBaseUrl),
72
+ onRequest: createRequestHandler(options, telemetry),
73
+ ...(options.enableWebsockets ? { onUpgrade: createUpgradeHandler(options, telemetry) } : {}),
74
+ }
75
+ }
@@ -1,12 +1,17 @@
1
1
  import type { Injector } from '@furystack/inject'
2
2
  import type { IncomingMessage, ServerResponse } from 'http'
3
3
 
4
+ /**
5
+ * The shape returned by every {@link RequestAction}. Consumed by the runtime
6
+ * to write the response (`statusCode`, `headers`, serialized `chunk`).
7
+ */
4
8
  export interface ActionResult<T> {
5
9
  statusCode: number
6
10
  headers: { [K: string]: string }
7
11
  chunk: T
8
12
  }
9
13
 
14
+ /** {@link ActionResult} with `application/json`. Default status 200. */
10
15
  export const JsonResult = <T extends object>(chunk: T, statusCode = 200, headers?: { [K: string]: string }) =>
11
16
  ({
12
17
  statusCode,
@@ -17,6 +22,7 @@ export const JsonResult = <T extends object>(chunk: T, statusCode = 200, headers
17
22
  },
18
23
  }) as ActionResult<T>
19
24
 
25
+ /** {@link ActionResult} with `plain/text`. Default status 200. */
20
26
  export const PlainTextResult = (text: string, statusCode = 200, headers?: { [K: string]: string }) =>
21
27
  ({
22
28
  statusCode,
@@ -27,6 +33,7 @@ export const PlainTextResult = (text: string, statusCode = 200, headers?: { [K:
27
33
  },
28
34
  }) as ActionResult<string>
29
35
 
36
+ /** {@link ActionResult} with `application/xml;charset=utf-8`. Default status 200. */
30
37
  export const XmlResult = (text: string, statusCode = 200, headers?: { [K: string]: string }) =>
31
38
  ({
32
39
  statusCode,
@@ -37,6 +44,7 @@ export const XmlResult = (text: string, statusCode = 200, headers?: { [K: string
37
44
  },
38
45
  }) as ActionResult<string>
39
46
 
47
+ /** {@link ActionResult} with no body. Use for `204 No Content` responses. */
40
48
  export const EmptyResult = (statusCode = 200, headers?: { [K: string]: string }) =>
41
49
  ({
42
50
  statusCode,
@@ -45,11 +53,22 @@ export const EmptyResult = (statusCode = 200, headers?: { [K: string]: string })
45
53
  },
46
54
  }) as ActionResult<undefined>
47
55
 
56
+ /**
57
+ * Sentinel result that signals "skip this action; let the runtime fall
58
+ * through to the next matching handler (or 404 if none)". Returned from
59
+ * an action when it decides not to handle the request after inspection.
60
+ */
48
61
  export const BypassResult = () =>
49
62
  ({
50
63
  chunk: 'BypassResult',
51
64
  }) as ActionResult<'BypassResult'>
52
65
 
66
+ /**
67
+ * Argument shape passed to a {@link RequestAction}. Conditional members
68
+ * (`getBody`, `getUrlParams`, `getQuery`, `headers`) are present only when
69
+ * the action's contract declares the corresponding key — keeping unrelated
70
+ * actions free of unused parser allocations.
71
+ */
53
72
  export type RequestActionOptions<T extends { result: unknown }> = {
54
73
  request: IncomingMessage
55
74
  response: ServerResponse
@@ -64,6 +83,12 @@ export type RequestActionOptions<T extends { result: unknown }> = {
64
83
  (T extends { result: unknown; query: infer U } ? { getQuery: () => U } : unknown) &
65
84
  (T extends { result: unknown; headers: infer U } ? { headers: U } : unknown)
66
85
 
86
+ /**
87
+ * Server-side implementation of a typed REST endpoint contract `T` (declared
88
+ * via `@furystack/rest`). Receives {@link RequestActionOptions} narrowed to
89
+ * the contract's body/url/query/headers and resolves to an {@link ActionResult}
90
+ * carrying the contract's `result` shape.
91
+ */
67
92
  export type RequestAction<T extends { result: unknown }> = (
68
93
  options: RequestActionOptions<T>,
69
94
  ) => Promise<ActionResult<T['result']>>
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { JsonResult } from './request-action-implementation.js'
3
+ import { compileApi, shouldExecRequest, type RestApiImplementation } from './rest-api-runtime.js'
4
+
5
+ type SampleApi = {
6
+ GET: {
7
+ '/items': { result: { items: number[] } }
8
+ '/items/:id': { url: { id: string }; result: { id: string } }
9
+ }
10
+ POST: {
11
+ '/items': { body: { name: string }; result: { name: string } }
12
+ }
13
+ }
14
+
15
+ const buildImplementation = (): RestApiImplementation<SampleApi> => ({
16
+ GET: {
17
+ '/items': async () => JsonResult({ items: [1, 2, 3] }),
18
+ '/items/:id': async ({ getUrlParams }) => JsonResult({ id: getUrlParams().id }),
19
+ },
20
+ POST: {
21
+ '/items': async ({ getBody }) => JsonResult(await getBody()),
22
+ },
23
+ })
24
+
25
+ describe('rest-api-runtime', () => {
26
+ describe('compileApi', () => {
27
+ it('normalises paths, prefixes the root, and preserves method→route ordering', () => {
28
+ const compiled = compileApi<SampleApi>(buildImplementation(), '/api')
29
+ expect(Object.keys(compiled)).toEqual(['GET', 'POST'])
30
+ const getRoutes = compiled.GET!
31
+ expect(getRoutes).toHaveLength(2)
32
+ expect(getRoutes[0].method).toBe('GET')
33
+ expect(getRoutes[0].fullPath).toBe('/api/items')
34
+ expect(getRoutes[1].fullPath).toBe('/api/items/:id')
35
+ })
36
+
37
+ it('produces matchers that extract url params', () => {
38
+ const compiled = compileApi<SampleApi>(buildImplementation(), '/api')
39
+ const itemById = compiled.GET!.find((r) => r.fullPath === '/api/items/:id')!
40
+ const result = itemById.matcher('/api/items/42')
41
+ expect(result && result.params).toEqual({ id: '42' })
42
+ })
43
+
44
+ it('returns false from matchers when the path does not match', () => {
45
+ const compiled = compileApi<SampleApi>(buildImplementation(), '/api')
46
+ const itemByIdMatcher = compiled.GET!.find((r) => r.fullPath === '/api/items/:id')!.matcher
47
+ expect(itemByIdMatcher('/api/items')).toBe(false)
48
+ expect(itemByIdMatcher('/other')).toBe(false)
49
+ })
50
+ })
51
+
52
+ describe('shouldExecRequest', () => {
53
+ const supportedMethods = ['GET', 'POST'] as string[]
54
+ const rootApiPath = '/api'
55
+
56
+ it('accepts supported methods whose URL starts with the root api path', () => {
57
+ expect(shouldExecRequest({ method: 'GET', url: '/api/items', rootApiPath, supportedMethods })).toBe(true)
58
+ })
59
+
60
+ it('accepts OPTIONS (preflight) even when it is not explicitly in supportedMethods', () => {
61
+ expect(shouldExecRequest({ method: 'OPTIONS', url: '/api/items', rootApiPath, supportedMethods })).toBe(true)
62
+ })
63
+
64
+ it('rejects unsupported methods', () => {
65
+ expect(shouldExecRequest({ method: 'DELETE', url: '/api/items', rootApiPath, supportedMethods })).toBe(false)
66
+ })
67
+
68
+ it('rejects URLs that do not match the root api path', () => {
69
+ expect(shouldExecRequest({ method: 'GET', url: '/other/items', rootApiPath, supportedMethods })).toBe(false)
70
+ })
71
+
72
+ it('rejects requests missing method or url', () => {
73
+ expect(shouldExecRequest({ method: undefined, url: '/api/items', rootApiPath, supportedMethods })).toBe(false)
74
+ expect(shouldExecRequest({ method: 'GET', url: undefined, rootApiPath, supportedMethods })).toBe(false)
75
+ })
76
+ })
77
+ })
@@ -0,0 +1,204 @@
1
+ import { IdentityContext, type User } from '@furystack/core'
2
+ import type { Injector } from '@furystack/inject'
3
+ import type { Method, RestApi } from '@furystack/rest'
4
+ import { RequestError, deserializeQueryString } from '@furystack/rest'
5
+ import { PathHelper, usingAsync } from '@furystack/utils'
6
+ import type { MatchFunction } from 'path-to-regexp'
7
+ import { match } from 'path-to-regexp'
8
+ import { ErrorAction } from './actions/error-action.js'
9
+ import { NotFoundAction } from './actions/not-found-action.js'
10
+ import { addCorsHeaders } from './add-cors-header.js'
11
+ import { HttpUserContext } from './http-user-context.js'
12
+ import type { CorsOptions } from './models/cors-options.js'
13
+ import { readPostBody } from './read-post-body.js'
14
+ import type { RequestAction } from './request-action-implementation.js'
15
+ import './server-response-extensions.js'
16
+ import type { OnRequest } from './http-server-pool.js'
17
+ import { DEFAULT_HOST } from './http-server-pool.js'
18
+
19
+ /**
20
+ * Concrete runtime representation of an API implementation — the object the
21
+ * application supplies to `useRestService` gets wrapped in this structure at
22
+ * registration time. Each method maps to an ordered array of compiled routes.
23
+ */
24
+ export type RestApiImplementation<T extends RestApi> = {
25
+ [TMethod in keyof T]: {
26
+ [TUrl in keyof T[TMethod]]: T[TMethod][TUrl] extends { result: unknown } ? RequestAction<T[TMethod][TUrl]> : never
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Compiled entry representing a single route: the HTTP method, the fully
32
+ * qualified path, a `path-to-regexp` matcher and the user-supplied
33
+ * action.
34
+ */
35
+ export type CompiledApiEntry = {
36
+ method: Method
37
+ fullPath: string
38
+ matcher: MatchFunction<Partial<Record<string, string | string[]>>>
39
+ action: RequestAction<any>
40
+ }
41
+
42
+ /**
43
+ * Grouping of {@link CompiledApiEntry}s keyed by HTTP method. Built once at
44
+ * setup time and reused for every incoming request.
45
+ */
46
+ export type CompiledApi = {
47
+ [K in Method]?: CompiledApiEntry[]
48
+ }
49
+
50
+ /** Options accepted by {@link onRestApiMessage}. */
51
+ export type OnRestApiMessageOptions = OnRequest & {
52
+ compiledApi: CompiledApi
53
+ hostName?: string
54
+ port: number
55
+ rootApiPath: string
56
+ injector: Injector
57
+ cors?: CorsOptions
58
+ supportedMethods: string[]
59
+ deserializeQueryParams?: (param: string) => Record<string, unknown>
60
+ }
61
+
62
+ /**
63
+ * Compiles a user-supplied REST API definition into the runtime shape used by
64
+ * {@link onRestApiMessage}. Each method's route map is transformed into an
65
+ * array of `{method, fullPath, matcher, action}` entries. The `root` prefix
66
+ * is prepended to every path.
67
+ */
68
+ export const compileApi = <T extends RestApi>(api: RestApiImplementation<T>, root: string): CompiledApi => {
69
+ const supportedMethods = Object.keys(api) as Method[]
70
+ const compiled: CompiledApi = {}
71
+ supportedMethods.forEach((method) => {
72
+ compiled[method] = Object.entries(api[method as keyof typeof api]).map(([path, action]) => {
73
+ const fullPath = `/${PathHelper.normalize(PathHelper.joinPaths(root, path))}`
74
+ const matcher = match(fullPath, { decode: decodeURIComponent })
75
+ return { method, fullPath, matcher, action: action as RequestAction<any> }
76
+ })
77
+ })
78
+ return compiled
79
+ }
80
+
81
+ /**
82
+ * Quick eligibility check executed by the server pool before invoking a
83
+ * {@link ServerApi.onRequest}. Avoids the cost of URL parsing and CORS header
84
+ * handling for requests this API clearly does not own.
85
+ */
86
+ export const shouldExecRequest = (options: {
87
+ method?: Method
88
+ url?: string
89
+ rootApiPath: string
90
+ supportedMethods: string[]
91
+ }): boolean => {
92
+ if (!options.method || !options.url) {
93
+ return false
94
+ }
95
+ if (!options.supportedMethods.includes(options.method) && options.method !== 'OPTIONS') {
96
+ return false
97
+ }
98
+ return PathHelper.matchesBaseUrl(options.url, options.rootApiPath)
99
+ }
100
+
101
+ const getActionFromEndpoint = (compiledApi: CompiledApi, fullUrl: URL, method: Method) => {
102
+ let resolvedParams: unknown
103
+ const action = compiledApi[method]?.find((route) => {
104
+ const result = route.matcher(fullUrl.pathname)
105
+ if (result) {
106
+ resolvedParams = result.params
107
+ }
108
+ return result
109
+ })
110
+ return (
111
+ action && {
112
+ ...action,
113
+ params: resolvedParams,
114
+ }
115
+ )
116
+ }
117
+
118
+ const executeAction = async (
119
+ options: OnRestApiMessageOptions & {
120
+ fullUrl: URL
121
+ action: RequestAction<{ body: object; result: object; query: object; url: object; headers: object }>
122
+ params: unknown
123
+ },
124
+ ): Promise<void> => {
125
+ const { injector, req, res, fullUrl, action, deserializeQueryParams, params } = options
126
+ await usingAsync(injector.createScope({ owner: action }), async (scope) => {
127
+ const httpUserContext = scope.get(HttpUserContext)
128
+ scope.bind(IdentityContext, () => ({
129
+ getCurrentUser: <TUser extends User>() => httpUserContext.getCurrentUser(req) as Promise<TUser>,
130
+ isAuthorized: (...roles) => httpUserContext.isAuthorized(req, ...roles),
131
+ isAuthenticated: () => httpUserContext.isAuthenticated(req),
132
+ }))
133
+ try {
134
+ const actionResult = await action({
135
+ request: req,
136
+ response: res,
137
+ injector: scope,
138
+ getBody: () => readPostBody<never>(req),
139
+ headers: req.headers,
140
+ getQuery: () =>
141
+ deserializeQueryParams ? deserializeQueryParams(fullUrl.search) : deserializeQueryString(fullUrl.search),
142
+ getUrlParams: () => params as object,
143
+ })
144
+ res.sendActionResult(actionResult)
145
+ } catch (error) {
146
+ const errorActionResult = await ErrorAction({
147
+ request: req,
148
+ response: res,
149
+ injector: scope,
150
+ getBody: async () => error,
151
+ })
152
+ res.sendActionResult(errorActionResult)
153
+ }
154
+ })
155
+ }
156
+
157
+ /**
158
+ * Handles a single incoming HTTP request matched by a REST API's
159
+ * {@link ServerApi.shouldExec}. Normalises the URL, applies CORS headers,
160
+ * short-circuits `OPTIONS` preflights and dispatches to the compiled action.
161
+ *
162
+ * Moved out of the deleted `ApiManager.onMessage` verbatim; behaviour is
163
+ * unchanged apart from injector resolution (`get` instead of `getInstance`).
164
+ */
165
+ export const onRestApiMessage = async (options: OnRestApiMessageOptions): Promise<void> => {
166
+ const protocol = 'http://'
167
+ const host = `${options.hostName ?? DEFAULT_HOST}:${options.port}`
168
+ const fullUrl = new URL(PathHelper.joinUrl(`${protocol}${host}`, options.req.url as string))
169
+
170
+ if (options.cors) {
171
+ addCorsHeaders(options.cors, options.req, options.res)
172
+ }
173
+
174
+ if (options.req.method === 'OPTIONS') {
175
+ options.res.writeHead(200)
176
+ options.res.end()
177
+ return
178
+ }
179
+
180
+ let action: ReturnType<typeof getActionFromEndpoint>
181
+ try {
182
+ action = getActionFromEndpoint(options.compiledApi, fullUrl, options.req.method?.toUpperCase() as Method)
183
+ } catch (error) {
184
+ const responseError =
185
+ error instanceof URIError ? new RequestError('Failed to decode URL path parameter', 400) : (error as Error)
186
+ const errorActionResult = await ErrorAction({
187
+ request: options.req,
188
+ response: options.res,
189
+ injector: options.injector,
190
+ getBody: async () => responseError,
191
+ })
192
+ options.res.sendActionResult(errorActionResult)
193
+ return
194
+ }
195
+
196
+ if (action) {
197
+ await executeAction({ ...options, ...action, fullUrl })
198
+ return
199
+ }
200
+
201
+ options.res.sendActionResult(
202
+ await NotFoundAction({ injector: options.injector, request: options.req, response: options.res }),
203
+ )
204
+ }