@furystack/rest-service 13.0.0 → 14.0.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 (224) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +47 -28
  3. package/esm/actions/get-current-user.spec.js +10 -6
  4. package/esm/actions/get-current-user.spec.js.map +1 -1
  5. package/esm/actions/is-authenticated.spec.js +10 -6
  6. package/esm/actions/is-authenticated.spec.js.map +1 -1
  7. package/esm/actions/logout-action.spec.js +11 -9
  8. package/esm/actions/logout-action.spec.js.map +1 -1
  9. package/esm/actions/logout.js +1 -1
  10. package/esm/actions/logout.js.map +1 -1
  11. package/esm/actions/password-login-action.js +1 -1
  12. package/esm/actions/password-login-action.js.map +1 -1
  13. package/esm/actions/password-login-action.spec.js +23 -28
  14. package/esm/actions/password-login-action.spec.js.map +1 -1
  15. package/esm/authenticate.js +1 -1
  16. package/esm/authenticate.js.map +1 -1
  17. package/esm/authenticate.spec.js +41 -33
  18. package/esm/authenticate.spec.js.map +1 -1
  19. package/esm/authorize.spec.js +34 -29
  20. package/esm/authorize.spec.js.map +1 -1
  21. package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
  22. package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
  23. package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
  24. package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
  25. package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
  26. package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
  27. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
  28. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
  29. package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
  30. package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
  31. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
  32. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
  33. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
  34. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
  35. package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
  36. package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
  37. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
  38. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
  39. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -1
  40. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
  41. package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -1
  42. package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
  43. package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
  44. package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
  45. package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
  46. package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
  47. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  48. package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
  49. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  50. package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
  51. package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
  52. package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
  53. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  54. package/esm/endpoint-generators/create-post-endpoint.js +8 -13
  55. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  56. package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
  57. package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
  58. package/esm/endpoint-generators/utils.d.ts +17 -2
  59. package/esm/endpoint-generators/utils.d.ts.map +1 -1
  60. package/esm/endpoint-generators/utils.js +33 -20
  61. package/esm/endpoint-generators/utils.js.map +1 -1
  62. package/esm/get-schema-from-api.d.ts +1 -1
  63. package/esm/get-schema-from-api.d.ts.map +1 -1
  64. package/esm/get-schema-from-api.spec.js.map +1 -1
  65. package/esm/helpers.d.ts +53 -87
  66. package/esm/helpers.d.ts.map +1 -1
  67. package/esm/helpers.js +91 -92
  68. package/esm/helpers.js.map +1 -1
  69. package/esm/helpers.spec.d.ts +2 -1
  70. package/esm/helpers.spec.d.ts.map +1 -1
  71. package/esm/helpers.spec.js +41 -38
  72. package/esm/helpers.spec.js.map +1 -1
  73. package/esm/http-authentication-settings.d.ts +35 -12
  74. package/esm/http-authentication-settings.d.ts.map +1 -1
  75. package/esm/http-authentication-settings.js +21 -29
  76. package/esm/http-authentication-settings.js.map +1 -1
  77. package/esm/http-server-pool.d.ts +64 -0
  78. package/esm/http-server-pool.d.ts.map +1 -0
  79. package/esm/http-server-pool.js +95 -0
  80. package/esm/http-server-pool.js.map +1 -0
  81. package/esm/http-server-pool.spec.d.ts +2 -0
  82. package/esm/http-server-pool.spec.d.ts.map +1 -0
  83. package/esm/http-server-pool.spec.js +193 -0
  84. package/esm/http-server-pool.spec.js.map +1 -0
  85. package/esm/http-user-context.d.ts +31 -44
  86. package/esm/http-user-context.d.ts.map +1 -1
  87. package/esm/http-user-context.js +76 -95
  88. package/esm/http-user-context.js.map +1 -1
  89. package/esm/http-user-context.spec.d.ts +8 -2
  90. package/esm/http-user-context.spec.d.ts.map +1 -1
  91. package/esm/http-user-context.spec.js +189 -337
  92. package/esm/http-user-context.spec.js.map +1 -1
  93. package/esm/index.d.ts +6 -4
  94. package/esm/index.d.ts.map +1 -1
  95. package/esm/index.js +6 -4
  96. package/esm/index.js.map +1 -1
  97. package/esm/login-response-strategy.d.ts +11 -10
  98. package/esm/login-response-strategy.d.ts.map +1 -1
  99. package/esm/login-response-strategy.js +7 -6
  100. package/esm/login-response-strategy.js.map +1 -1
  101. package/esm/login-response-strategy.spec.js +25 -29
  102. package/esm/login-response-strategy.spec.js.map +1 -1
  103. package/esm/proxy-manager.spec.js +152 -176
  104. package/esm/proxy-manager.spec.js.map +1 -1
  105. package/esm/proxy-runtime.d.ts +27 -0
  106. package/esm/proxy-runtime.d.ts.map +1 -0
  107. package/esm/proxy-runtime.js +45 -0
  108. package/esm/proxy-runtime.js.map +1 -0
  109. package/esm/proxy-runtime.spec.d.ts +2 -0
  110. package/esm/proxy-runtime.spec.d.ts.map +1 -0
  111. package/esm/proxy-runtime.spec.js +39 -0
  112. package/esm/proxy-runtime.spec.js.map +1 -0
  113. package/esm/rest-api-runtime.d.ts +76 -0
  114. package/esm/rest-api-runtime.d.ts.map +1 -0
  115. package/esm/rest-api-runtime.js +131 -0
  116. package/esm/rest-api-runtime.js.map +1 -0
  117. package/esm/rest-api-runtime.spec.d.ts +2 -0
  118. package/esm/rest-api-runtime.spec.d.ts.map +1 -0
  119. package/esm/rest-api-runtime.spec.js +58 -0
  120. package/esm/rest-api-runtime.spec.js.map +1 -0
  121. package/esm/rest-service.integration.spec.d.ts +1 -1
  122. package/esm/rest-service.integration.spec.d.ts.map +1 -1
  123. package/esm/rest-service.integration.spec.js +57 -80
  124. package/esm/rest-service.integration.spec.js.map +1 -1
  125. package/esm/rest.integration.test.js +26 -84
  126. package/esm/rest.integration.test.js.map +1 -1
  127. package/esm/server-telemetry.d.ts +57 -0
  128. package/esm/server-telemetry.d.ts.map +1 -0
  129. package/esm/server-telemetry.js +26 -0
  130. package/esm/server-telemetry.js.map +1 -0
  131. package/esm/server-telemetry.spec.d.ts +2 -0
  132. package/esm/server-telemetry.spec.d.ts.map +1 -0
  133. package/esm/server-telemetry.spec.js +52 -0
  134. package/esm/server-telemetry.spec.js.map +1 -0
  135. package/esm/static-server-manager.spec.js +60 -125
  136. package/esm/static-server-manager.spec.js.map +1 -1
  137. package/esm/static-site-runtime.d.ts +21 -0
  138. package/esm/static-site-runtime.d.ts.map +1 -0
  139. package/esm/static-site-runtime.js +48 -0
  140. package/esm/static-site-runtime.js.map +1 -0
  141. package/esm/static-site-runtime.spec.d.ts +2 -0
  142. package/esm/static-site-runtime.spec.d.ts.map +1 -0
  143. package/esm/static-site-runtime.spec.js +30 -0
  144. package/esm/static-site-runtime.spec.js.map +1 -0
  145. package/esm/user-store.d.ts +39 -0
  146. package/esm/user-store.d.ts.map +1 -0
  147. package/esm/user-store.js +62 -0
  148. package/esm/user-store.js.map +1 -0
  149. package/esm/user-store.spec.d.ts +2 -0
  150. package/esm/user-store.spec.d.ts.map +1 -0
  151. package/esm/user-store.spec.js +72 -0
  152. package/esm/user-store.spec.js.map +1 -0
  153. package/esm/validate.integration.spec.js +26 -28
  154. package/esm/validate.integration.spec.js.map +1 -1
  155. package/package.json +8 -8
  156. package/src/actions/get-current-user.spec.ts +11 -10
  157. package/src/actions/is-authenticated.spec.ts +12 -7
  158. package/src/actions/logout-action.spec.ts +12 -12
  159. package/src/actions/logout.ts +1 -1
  160. package/src/actions/password-login-action.spec.ts +30 -30
  161. package/src/actions/password-login-action.ts +1 -1
  162. package/src/authenticate.spec.ts +45 -51
  163. package/src/authenticate.ts +1 -1
  164. package/src/authorize.spec.ts +37 -39
  165. package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
  166. package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
  167. package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
  168. package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
  169. package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
  170. package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
  171. package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -2
  172. package/src/endpoint-generators/create-get-schema-action.ts +1 -1
  173. package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
  174. package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
  175. package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
  176. package/src/endpoint-generators/create-post-endpoint.ts +13 -23
  177. package/src/endpoint-generators/utils.ts +36 -28
  178. package/src/get-schema-from-api.spec.ts +1 -1
  179. package/src/get-schema-from-api.ts +1 -1
  180. package/src/helpers.spec.ts +51 -41
  181. package/src/helpers.ts +140 -109
  182. package/src/http-authentication-settings.ts +51 -25
  183. package/src/http-server-pool.spec.ts +220 -0
  184. package/src/http-server-pool.ts +169 -0
  185. package/src/http-user-context.spec.ts +203 -383
  186. package/src/http-user-context.ts +114 -91
  187. package/src/index.ts +6 -4
  188. package/src/login-response-strategy.spec.ts +32 -31
  189. package/src/login-response-strategy.ts +13 -12
  190. package/src/proxy-manager.spec.ts +152 -182
  191. package/src/proxy-runtime.spec.ts +50 -0
  192. package/src/proxy-runtime.ts +75 -0
  193. package/src/rest-api-runtime.spec.ts +77 -0
  194. package/src/rest-api-runtime.ts +204 -0
  195. package/src/rest-service.integration.spec.ts +65 -85
  196. package/src/rest.integration.test.ts +27 -87
  197. package/src/server-telemetry.spec.ts +56 -0
  198. package/src/server-telemetry.ts +51 -0
  199. package/src/static-server-manager.spec.ts +63 -151
  200. package/src/static-site-runtime.spec.ts +39 -0
  201. package/src/static-site-runtime.ts +75 -0
  202. package/src/user-store.spec.ts +86 -0
  203. package/src/user-store.ts +68 -0
  204. package/src/validate.integration.spec.ts +39 -35
  205. package/esm/api-manager.d.ts +0 -104
  206. package/esm/api-manager.d.ts.map +0 -1
  207. package/esm/api-manager.js +0 -179
  208. package/esm/api-manager.js.map +0 -1
  209. package/esm/proxy-manager.d.ts +0 -53
  210. package/esm/proxy-manager.d.ts.map +0 -1
  211. package/esm/proxy-manager.js +0 -84
  212. package/esm/proxy-manager.js.map +0 -1
  213. package/esm/server-manager.d.ts +0 -48
  214. package/esm/server-manager.d.ts.map +0 -1
  215. package/esm/server-manager.js +0 -100
  216. package/esm/server-manager.js.map +0 -1
  217. package/esm/static-server-manager.d.ts +0 -19
  218. package/esm/static-server-manager.d.ts.map +0 -1
  219. package/esm/static-server-manager.js +0 -65
  220. package/esm/static-server-manager.js.map +0 -1
  221. package/src/api-manager.ts +0 -304
  222. package/src/proxy-manager.ts +0 -102
  223. package/src/server-manager.ts +0 -135
  224. package/src/static-server-manager.ts +0 -90
package/src/helpers.ts CHANGED
@@ -1,55 +1,138 @@
1
- import type { User } from '@furystack/core'
2
- import { useSystemIdentityContext } from '@furystack/core'
1
+ import { useSystemIdentityContext, type User } from '@furystack/core'
3
2
  import type { Injector } from '@furystack/inject'
4
- import type { RestApi } from '@furystack/rest'
5
3
  import type { DataSet } from '@furystack/repository'
4
+ import type { RestApi } from '@furystack/rest'
6
5
  import { PasswordAuthenticator } from '@furystack/security'
7
-
8
- import type { ImplementApiOptions } from './api-manager.js'
9
- import { ApiManager } from './api-manager.js'
6
+ import { PathHelper } from '@furystack/utils'
10
7
  import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
11
8
  import { createBasicAuthProvider } from './authentication-providers/basic-auth-provider.js'
12
9
  import { createCookieAuthProvider } from './authentication-providers/cookie-auth-provider.js'
13
10
  import { authenticateUserWithDataSet, findSessionById, findUserByName } from './authentication-providers/helpers.js'
14
- import { HttpAuthenticationSettings } from './http-authentication-settings.js'
15
- import type { DefaultSession } from './models/default-session.js'
16
- import type { ProxyOptions } from './proxy-manager.js'
17
- import { ProxyManager } from './proxy-manager.js'
18
- import type { StaticServerOptions } from './static-server-manager.js'
19
- import { StaticServerManager } from './static-server-manager.js'
11
+ import { CreateGetOpenApiDocumentAction } from './endpoint-generators/create-get-openapi-document-action.js'
12
+ import { CreateGetSchemaAction } from './endpoint-generators/create-get-schema-action.js'
13
+ import { defaultHttpAuthenticationSettings, HttpAuthenticationSettings } from './http-authentication-settings.js'
14
+ import { HttpServerPoolToken, type ServerApi } from './http-server-pool.js'
15
+ import { HttpUserContext } from './http-user-context.js'
16
+ import type { CorsOptions } from './models/cors-options.js'
17
+ import { buildProxyServerApi, type ProxyOptions } from './proxy-runtime.js'
18
+ import { compileApi, onRestApiMessage, type RestApiImplementation, shouldExecRequest } from './rest-api-runtime.js'
19
+ import { ServerTelemetryToken } from './server-telemetry.js'
20
+ import { buildStaticSiteServerApi, type StaticServerOptions } from './static-site-runtime.js'
20
21
 
21
22
  /**
22
- * Sets up the @furystack/rest-service with the provided settings
23
- * @param api The API implementation details
24
- * @returns a promise that resolves when the API is added to the server
23
+ * Options accepted by {@link useRestService}. Mirrors the pre-migration
24
+ * shape, minus fields that have moved to tokens.
25
25
  */
26
- export const useRestService = async <T extends RestApi>(api: ImplementApiOptions<T>) =>
27
- await api.injector.getInstance(ApiManager).addApi({ ...api })
26
+ export interface ImplementApiOptions<T extends RestApi> {
27
+ /** The structure of the implemented API. */
28
+ api: RestApiImplementation<T>
29
+ /** Injector used to resolve request-scoped services and to acquire the HTTP server. */
30
+ injector: Injector
31
+ /** Optional host name; defaults to `localhost`. */
32
+ hostName?: string
33
+ /** Root path prepended to every API route. */
34
+ root: string
35
+ /** Port the API should listen on. */
36
+ port: number
37
+ /** Optional CORS configuration applied to every matched request. */
38
+ cors?: CorsOptions
39
+ /** Optional query-string deserialiser override. */
40
+ deserializeQueryParams?: (param: string) => Record<string, unknown>
41
+ /** When `true`, adds `GET /schema` and `GET /openapi.json` endpoints. */
42
+ enableGetSchema?: boolean
43
+ /** OpenAPI display name. */
44
+ name?: string
45
+ /** OpenAPI description. */
46
+ description?: string
47
+ /** OpenAPI version. */
48
+ version?: string
49
+ }
28
50
 
29
51
  /**
30
- * Sets up the HTTP Authentication with pluggable providers.
52
+ * Acquires a pooled HTTP server, compiles the user-supplied API definition
53
+ * and attaches a {@link ServerApi} that dispatches matching requests.
31
54
  *
32
- * Registers Basic Auth (if enabled) and Cookie Auth as built-in providers,
33
- * then appends any custom providers passed via settings.
55
+ * Public contract is unchanged from the pre-migration release; internally
56
+ * everything goes through the functional tokens.
57
+ */
58
+ export const useRestService = async <T extends RestApi>(options: ImplementApiOptions<T>): Promise<ServerApi> => {
59
+ const {
60
+ injector,
61
+ api,
62
+ hostName,
63
+ port,
64
+ root,
65
+ cors,
66
+ deserializeQueryParams,
67
+ enableGetSchema,
68
+ name,
69
+ description,
70
+ version,
71
+ } = options
72
+
73
+ const extendedApi: typeof api = enableGetSchema
74
+ ? ({
75
+ ...api,
76
+ GET: {
77
+ ...api.GET,
78
+ '/schema': CreateGetSchemaAction(api, name, description, version),
79
+ '/openapi.json': CreateGetOpenApiDocumentAction(api, name, description, version),
80
+ },
81
+ } as typeof api)
82
+ : api
83
+
84
+ const supportedMethods = Object.keys(extendedApi)
85
+ const rootApiPath = PathHelper.normalize(root)
86
+ const compiledApi = compileApi(extendedApi, root)
87
+
88
+ const serverApi: ServerApi = {
89
+ shouldExec: ({ req }) =>
90
+ shouldExecRequest({
91
+ method: req.method?.toUpperCase() as never,
92
+ url: PathHelper.normalize(req.url || ''),
93
+ rootApiPath,
94
+ supportedMethods,
95
+ }),
96
+ onRequest: (msg) =>
97
+ onRestApiMessage({
98
+ ...msg,
99
+ compiledApi,
100
+ rootApiPath,
101
+ port,
102
+ supportedMethods,
103
+ cors,
104
+ injector,
105
+ hostName,
106
+ deserializeQueryParams,
107
+ }),
108
+ }
109
+
110
+ const pool = injector.get(HttpServerPoolToken)
111
+ const record = await pool.acquire({ port, hostName })
112
+ record.apis.push(serverApi)
113
+ return serverApi
114
+ }
115
+
116
+ /**
117
+ * Installs HTTP authentication on the given injector: builds the provider
118
+ * chain (Basic + Cookie by default), appends any caller-provided providers
119
+ * and rebinds {@link HttpAuthenticationSettings}.
34
120
  *
35
- * @param injector The Injector instance
36
- * @param settings Settings for HTTP Authentication
121
+ * Prerequisite: {@link UserStore} and {@link SessionStore} must be bound to
122
+ * persistent implementations before calling this helper, since the Basic
123
+ * and Cookie providers need to read users and sessions immediately.
37
124
  */
38
- export const useHttpAuthentication = <TUser extends User, TSession extends DefaultSession>(
39
- injector: Injector,
40
- settings?: Partial<HttpAuthenticationSettings<TUser, TSession>>,
41
- ) => {
42
- const mergedSettings = Object.assign(new HttpAuthenticationSettings<TUser, TSession>(), settings)
125
+ export const useHttpAuthentication = (injector: Injector, overrides?: Partial<HttpAuthenticationSettings>): void => {
126
+ const mergedSettings: HttpAuthenticationSettings = {
127
+ ...defaultHttpAuthenticationSettings(),
128
+ ...overrides,
129
+ }
43
130
  const systemInjector = useSystemIdentityContext({ injector, username: 'useHttpAuthentication' })
44
- const passwordAuthenticator = injector.getInstance(PasswordAuthenticator)
45
- const userDataSet = mergedSettings.getUserDataSet(systemInjector) as unknown as DataSet<User, 'username'>
46
- const sessionDataSet = mergedSettings.getSessionDataSet(systemInjector) as unknown as DataSet<
47
- DefaultSession,
48
- 'sessionId'
49
- >
131
+ const passwordAuthenticator = injector.get(PasswordAuthenticator)
132
+ const userDataSet = systemInjector.get(mergedSettings.userDataSet) as unknown as DataSet<User, 'username'>
133
+ const sessionDataSet = systemInjector.get(mergedSettings.sessionDataSet)
50
134
 
51
135
  const providers: AuthenticationProvider[] = []
52
-
53
136
  if (mergedSettings.enableBasicAuth) {
54
137
  providers.push(
55
138
  createBasicAuthProvider((username, password) =>
@@ -57,7 +140,6 @@ export const useHttpAuthentication = <TUser extends User, TSession extends Defau
57
140
  ),
58
141
  )
59
142
  }
60
-
61
143
  providers.push(
62
144
  createCookieAuthProvider(
63
145
  mergedSettings.cookieName,
@@ -65,90 +147,39 @@ export const useHttpAuthentication = <TUser extends User, TSession extends Defau
65
147
  (username) => findUserByName(userDataSet, systemInjector, username),
66
148
  ),
67
149
  )
68
-
69
- providers.push(...(settings?.authenticationProviders ?? []))
150
+ providers.push(...(overrides?.authenticationProviders ?? []))
70
151
  mergedSettings.authenticationProviders = providers
71
152
 
72
- injector.setExplicitInstance(mergedSettings, HttpAuthenticationSettings)
153
+ injector.bind(HttpAuthenticationSettings, () => mergedSettings)
154
+ injector.invalidate(HttpAuthenticationSettings)
155
+ injector.invalidate(HttpUserContext)
73
156
  }
74
157
 
75
158
  /**
76
- * Sets up a static file server
77
- * @param options The settings for the static file server
78
- * @param options.injector The Injector instance
79
- * @param options.settings Settings for the static file server
80
- * @returns a promise that resolves when the server is ready
159
+ * Attaches a static-file {@link ServerApi} to the pooled HTTP server
160
+ * identified by `options.port`/`options.hostName`.
81
161
  */
82
- export const useStaticFiles = (options: { injector: Injector } & StaticServerOptions) => {
162
+ export const useStaticFiles = async (options: { injector: Injector } & StaticServerOptions): Promise<ServerApi> => {
83
163
  const { injector, ...settings } = options
84
- return injector.getInstance(StaticServerManager).addStaticSite(settings)
164
+ const pool = injector.get(HttpServerPoolToken)
165
+ const record = await pool.acquire({ port: settings.port, hostName: settings.hostName })
166
+ const serverApi = buildStaticSiteServerApi(settings)
167
+ record.apis.push(serverApi)
168
+ return serverApi
85
169
  }
86
170
 
87
171
  /**
88
- * Sets up a proxy server that forwards HTTP requests from a source URL to a target URL.
89
- *
90
- * The proxy acts as an intermediary, forwarding requests and responses while allowing
91
- * transformation of headers, cookies, and paths. It returns 502 Bad Gateway on errors
92
- * and emits 'onProxyFailed' events for monitoring.
93
- *
94
- * WebSocket connections can also be proxied by setting `enableWebsockets: true`, allowing
95
- * bidirectional real-time communication through the proxy.
96
- *
97
- * @param options The settings for the proxy server
98
- * @param options.injector The Injector instance
99
- * @param options.sourceBaseUrl The base URL path to match for proxying (e.g., '/api', '/old').
100
- * Can be specified with or without a trailing slash.
101
- * @param options.targetBaseUrl The target server URL (must be a valid HTTP/HTTPS URL)
102
- * @param options.pathRewrite Optional function to rewrite the path before forwarding.
103
- * Receives the path after sourceBaseUrl, including leading slash and query string.
104
- * Example: for 'GET /api/users?active=true' with sourceBaseUrl='/api',
105
- * pathRewrite receives '/users?active=true'
106
- * @param options.sourceHostName The hostname for the source server (optional, defaults to all interfaces)
107
- * @param options.sourcePort The port for the source server
108
- * @param options.headers Optional function to transform request headers.
109
- * **Note**: Receives headers AFTER filtering hop-by-hop headers
110
- * (Connection, Keep-Alive, Transfer-Encoding, Upgrade, etc.) for security
111
- * and protocol compliance. The proxy automatically adds X-Forwarded-* headers.
112
- * This transformation applies to both HTTP and WebSocket requests.
113
- * @param options.cookies Optional function to transform request cookies (array of cookie strings)
114
- * @param options.responseCookies Optional function to transform response Set-Cookie headers
115
- * @param options.timeout Optional timeout in milliseconds for proxy requests (default: 30000).
116
- * If exceeded, the request is aborted and 502 is returned.
117
- * Applies to both HTTP and WebSocket upgrade requests.
118
- * @param options.enableWebsockets Optional flag to enable WebSocket proxying (default: false).
119
- * When enabled, WebSocket upgrade requests will be forwarded to the target.
120
- * @returns a promise that resolves when the proxy is set up
121
- * @example
122
- * ```ts
123
- * // Basic HTTP proxy with timeout
124
- * await useProxy({
125
- * injector,
126
- * sourceBaseUrl: '/api',
127
- * targetBaseUrl: 'https://api.example.com',
128
- * sourcePort: 3000,
129
- * timeout: 5000,
130
- * })
131
- *
132
- * // Proxy with WebSocket support
133
- * await useProxy({
134
- * injector,
135
- * sourceBaseUrl: '/ws',
136
- * targetBaseUrl: 'https://ws.example.com',
137
- * sourcePort: 3000,
138
- * enableWebsockets: true,
139
- * })
140
- *
141
- * // Proxy with error monitoring (HTTP and WebSocket)
142
- * const proxyManager = injector.getInstance(ProxyManager)
143
- * proxyManager.subscribe('onProxyFailed', ({ from, to, error }) => {
144
- * console.error(`HTTP Proxy failed: ${from} -> ${to}`, error)
145
- * })
146
- * proxyManager.subscribe('onWebSocketProxyFailed', ({ from, to, error }) => {
147
- * console.error(`WebSocket Proxy failed: ${from} -> ${to}`, error)
148
- * })
149
- * ```
172
+ * Attaches a proxy {@link ServerApi} that forwards HTTP (and optionally
173
+ * WebSocket) traffic from `options.sourceBaseUrl` to `options.targetBaseUrl`.
174
+ * Emits `onProxyFailed` and `onWebSocketProxyFailed` events into the shared
175
+ * {@link ServerTelemetryToken} — subscribe there for observability.
150
176
  */
151
- export const useProxy = (options: { injector: Injector } & ProxyOptions) => {
177
+ export const useProxy = async (options: { injector: Injector } & ProxyOptions): Promise<ServerApi> => {
152
178
  const { injector, ...settings } = options
153
- return injector.getInstance(ProxyManager).addProxy(settings)
179
+ const telemetry = injector.get(ServerTelemetryToken)
180
+ const pool = injector.get(HttpServerPoolToken)
181
+ const record = await pool.acquire({ port: settings.sourcePort, hostName: settings.sourceHostName })
182
+ const serverApi = buildProxyServerApi(settings, telemetry)
183
+ record.apis.push(serverApi)
184
+ return serverApi
154
185
  }
@@ -1,32 +1,58 @@
1
- import { User } from '@furystack/core'
2
- import type { Constructable, Injector } from '@furystack/inject'
3
- import { Injectable } from '@furystack/inject'
4
- import { getDataSetFor } from '@furystack/repository'
1
+ import type { User } from '@furystack/core'
2
+ import { defineService, type Token } from '@furystack/inject'
3
+ import type { DataSetToken } from '@furystack/repository'
5
4
  import type { AuthenticationProvider } from './authentication-providers/authentication-provider.js'
6
- import { DefaultSession } from './models/default-session.js'
5
+ import type { DefaultSession } from './models/default-session.js'
6
+ import { SessionDataSet, UserDataSet } from './user-store.js'
7
7
 
8
8
  /**
9
- * Authentication settings object for FuryStack HTTP Api
9
+ * HTTP-authentication settings — rebound by {@link useHttpAuthentication}
10
+ * during application setup and consumed by {@link HttpUserContext},
11
+ * cookie-login strategies and OpenAPI generators.
12
+ *
13
+ * The field shape reflects the functional-DI redesign:
14
+ *
15
+ * - `userDataSet` / `sessionDataSet` are token references (not accessors).
16
+ * Consumers resolve them through the injector at the appropriate scope.
17
+ * - `authenticationProviders` is a simple ordered list; `HttpUserContext`
18
+ * walks it on every unauthenticated request.
10
19
  */
11
- @Injectable({ lifetime: 'singleton' })
12
- export class HttpAuthenticationSettings<TUser extends User, TSession extends DefaultSession> {
13
- public model: Constructable<TUser> = User as Constructable<TUser>
14
-
15
- public getUserDataSet = (injector: Injector) =>
16
- getDataSetFor(injector, this.model, 'username' as 'username' & keyof TUser)
17
-
18
- public sessionModel: Constructable<TSession> = DefaultSession as Constructable<TSession>
19
-
20
- public getSessionDataSet = (injector: Injector) =>
21
- getDataSetFor(injector, this.sessionModel, 'sessionId' as 'sessionId' & keyof TSession)
22
-
23
- public cookieName = 'fss'
24
- public enableBasicAuth = true
25
-
20
+ export interface HttpAuthenticationSettings {
21
+ /** Data set token for user lookups. */
22
+ userDataSet: DataSetToken<User, 'username'>
23
+ /** Data set token for session lookups. */
24
+ sessionDataSet: DataSetToken<DefaultSession, 'sessionId'>
25
+ /** Cookie name used by the built-in cookie-auth provider. */
26
+ cookieName: string
27
+ /** Whether the built-in Basic Auth provider is installed by {@link useHttpAuthentication}. */
28
+ enableBasicAuth: boolean
26
29
  /**
27
- * Ordered list of authentication providers. Populated by {@link useHttpAuthentication}
28
- * and extended by `useJwtAuthentication()` or other auth plugins.
29
- * Safe to mutate only during setup, before the first request is served.
30
+ * Ordered list of authentication providers. Populated during
31
+ * {@link useHttpAuthentication} and extended by plugins such as
32
+ * `useJwtAuthentication`.
30
33
  */
31
- public authenticationProviders: AuthenticationProvider[] = []
34
+ authenticationProviders: AuthenticationProvider[]
32
35
  }
36
+
37
+ /**
38
+ * Returns a fresh copy of the default {@link HttpAuthenticationSettings}.
39
+ * Used as the starting point inside {@link useHttpAuthentication}.
40
+ */
41
+ export const defaultHttpAuthenticationSettings = (): HttpAuthenticationSettings => ({
42
+ userDataSet: UserDataSet,
43
+ sessionDataSet: SessionDataSet,
44
+ cookieName: 'fss',
45
+ enableBasicAuth: true,
46
+ authenticationProviders: [],
47
+ })
48
+
49
+ /**
50
+ * DI token carrying the current {@link HttpAuthenticationSettings}. Rebind
51
+ * via {@link useHttpAuthentication} (preferred) or directly through
52
+ * {@link Injector.bind} for advanced scenarios.
53
+ */
54
+ export const HttpAuthenticationSettings: Token<HttpAuthenticationSettings, 'singleton'> = defineService({
55
+ name: 'furystack/rest-service/HttpAuthenticationSettings',
56
+ lifetime: 'singleton',
57
+ factory: () => defaultHttpAuthenticationSettings(),
58
+ })
@@ -0,0 +1,220 @@
1
+ import { getPort } from '@furystack/core/port-generator'
2
+ import { createInjector } from '@furystack/inject'
3
+ import { usingAsync } from '@furystack/utils'
4
+ import type { IncomingMessage, ServerResponse } from 'http'
5
+ import { describe, expect, it, vi } from 'vitest'
6
+ import { WebSocket } from 'ws'
7
+ import { HttpServerPoolToken, type OnRequest, type OnUpgrade, type ServerApi } from './http-server-pool.js'
8
+ import { ServerTelemetryToken } from './server-telemetry.js'
9
+
10
+ /** Returns a `ServerApi` that executes `handler` on every matching request. */
11
+ const makeApi = (handler: (options: OnRequest) => Promise<void>): ServerApi => ({
12
+ shouldExec: () => true,
13
+ onRequest: handler,
14
+ })
15
+
16
+ describe('HttpServerPool', () => {
17
+ it('Should return the cached record on subsequent acquire calls for the same url', async () => {
18
+ await usingAsync(createInjector(), async (injector) => {
19
+ const pool = injector.get(HttpServerPoolToken)
20
+ const port = getPort()
21
+ const first = await pool.acquire({ port })
22
+ const second = await pool.acquire({ port })
23
+ expect(second).toBe(first)
24
+ })
25
+ })
26
+
27
+ it('Should de-duplicate concurrent acquire calls into a single server creation', async () => {
28
+ await usingAsync(createInjector(), async (injector) => {
29
+ const pool = injector.get(HttpServerPoolToken)
30
+ const port = getPort()
31
+ const [a, b, c] = await Promise.all([pool.acquire({ port }), pool.acquire({ port }), pool.acquire({ port })])
32
+ expect(b).toBe(a)
33
+ expect(c).toBe(a)
34
+ })
35
+ })
36
+
37
+ it('Should emit onServerListening when a new record is opened', async () => {
38
+ await usingAsync(createInjector(), async (injector) => {
39
+ const telemetry = injector.get(ServerTelemetryToken)
40
+ const listening = vi.fn()
41
+ telemetry.subscribe('onServerListening', listening)
42
+
43
+ const port = getPort()
44
+ await injector.get(HttpServerPoolToken).acquire({ port })
45
+ expect(listening).toHaveBeenCalledWith({ url: `http://localhost:${port}`, port, hostName: undefined })
46
+ })
47
+ })
48
+
49
+ it('Should destroy the socket and route the rejection through telemetry when onUpgrade rejects', async () => {
50
+ const injector = createInjector()
51
+ try {
52
+ const pool = injector.get(HttpServerPoolToken)
53
+ const telemetry = injector.get(ServerTelemetryToken)
54
+ const onRequestFailed = vi.fn()
55
+ telemetry.subscribe('onRequestFailed', onRequestFailed)
56
+
57
+ const port = getPort()
58
+ const record = await pool.acquire({ port })
59
+
60
+ const rejection = new Error('upgrade boom')
61
+ const onUpgrade = vi.fn<(options: OnUpgrade) => Promise<void>>().mockRejectedValue(rejection)
62
+ record.apis.push({
63
+ shouldExec: () => true,
64
+ onRequest: () => Promise.resolve(),
65
+ onUpgrade,
66
+ })
67
+
68
+ const socketClosed = new Promise<void>((resolve) => {
69
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`)
70
+ ws.on('close', () => resolve())
71
+ ws.on('error', () => resolve())
72
+ })
73
+ await socketClosed
74
+ await new Promise((r) => setTimeout(r, 20))
75
+
76
+ expect(onUpgrade).toHaveBeenCalledTimes(1)
77
+ expect(onRequestFailed).toHaveBeenCalledTimes(1)
78
+ const [error] = onRequestFailed.mock.calls[0][0] as [unknown, IncomingMessage, ServerResponse]
79
+ expect(error).toBe(rejection)
80
+ } finally {
81
+ await injector[Symbol.asyncDispose]()
82
+ }
83
+ })
84
+
85
+ it('Should destroy the socket when no API claims the upgrade', async () => {
86
+ const injector = createInjector()
87
+ try {
88
+ const pool = injector.get(HttpServerPoolToken)
89
+ const port = getPort()
90
+ await pool.acquire({ port })
91
+
92
+ const ws = new WebSocket(`ws://127.0.0.1:${port}`)
93
+ await new Promise<void>((resolve) => {
94
+ ws.on('close', () => resolve())
95
+ ws.on('error', () => resolve())
96
+ })
97
+ expect(ws.readyState).toBe(WebSocket.CLOSED)
98
+ } finally {
99
+ await injector[Symbol.asyncDispose]()
100
+ }
101
+ })
102
+
103
+ it('Should destroy the raw socket when no API matches a normal request', async () => {
104
+ const injector = createInjector()
105
+ try {
106
+ const pool = injector.get(HttpServerPoolToken)
107
+ const port = getPort()
108
+ await pool.acquire({ port })
109
+
110
+ await expect(fetch(`http://127.0.0.1:${port}/nothing`)).rejects.toBeDefined()
111
+ } finally {
112
+ await injector[Symbol.asyncDispose]()
113
+ }
114
+ })
115
+
116
+ it('Should surface onRequest rejections through telemetry without crashing the server', async () => {
117
+ const injector = createInjector()
118
+ try {
119
+ const pool = injector.get(HttpServerPoolToken)
120
+ const telemetry = injector.get(ServerTelemetryToken)
121
+ const onRequestFailed = vi.fn()
122
+ telemetry.subscribe('onRequestFailed', onRequestFailed)
123
+
124
+ const port = getPort()
125
+ const record = await pool.acquire({ port })
126
+ const rejection = new Error('request boom')
127
+ record.apis.push(
128
+ makeApi(async ({ res }) => {
129
+ res.end()
130
+ throw rejection
131
+ }),
132
+ )
133
+
134
+ await fetch(`http://127.0.0.1:${port}/test`)
135
+ await new Promise((r) => setTimeout(r, 20))
136
+
137
+ expect(onRequestFailed).toHaveBeenCalledTimes(1)
138
+ const [error] = onRequestFailed.mock.calls[0][0] as [unknown, IncomingMessage, ServerResponse]
139
+ expect(error).toBe(rejection)
140
+ } finally {
141
+ await injector[Symbol.asyncDispose]()
142
+ }
143
+ })
144
+
145
+ it('Should destroy open client sockets during disposal', async () => {
146
+ const injector = createInjector()
147
+ const pool = injector.get(HttpServerPoolToken)
148
+ const port = getPort()
149
+ const record = await pool.acquire({ port })
150
+
151
+ record.apis.push({
152
+ shouldExec: () => true,
153
+ onRequest: async ({ res }) => {
154
+ res.writeHead(200)
155
+ res.write('streaming')
156
+ },
157
+ })
158
+
159
+ // Keep a client socket open so the dispose path has something to destroy.
160
+ const inflight = fetch(`http://127.0.0.1:${port}/slow`).catch(() => undefined)
161
+ await new Promise((r) => setTimeout(r, 50))
162
+
163
+ await injector[Symbol.asyncDispose]()
164
+ await inflight
165
+ })
166
+
167
+ it('Should absorb server.close errors during dispose without crashing', async () => {
168
+ const injector = createInjector()
169
+ const pool = injector.get(HttpServerPoolToken)
170
+ const port = getPort()
171
+ const record = await pool.acquire({ port })
172
+
173
+ // Force `server.close` to invoke its callback with an error -- the pool
174
+ // wraps every close in `Promise.allSettled`, so the rejected branch
175
+ // (`if (err) { reject(err); return }`) is exercised but the injector's
176
+ // dispose still resolves cleanly.
177
+ const closeError = new Error('close boom')
178
+ const closeSpy = vi.fn((cb?: (err?: Error) => void) => {
179
+ cb?.(closeError)
180
+ return record.server
181
+ })
182
+ record.server.close = closeSpy as unknown as typeof record.server.close
183
+ ;(record.server as unknown as { off: (...args: unknown[]) => void }).off = () => undefined
184
+
185
+ await expect(injector[Symbol.asyncDispose]()).resolves.toBeUndefined()
186
+ expect(closeSpy).toHaveBeenCalled()
187
+ })
188
+
189
+ it('Should drain in-flight acquire promises during dispose', async () => {
190
+ const injector = createInjector()
191
+ const port = getPort()
192
+ const pool = injector.get(HttpServerPoolToken)
193
+ const inflight = pool.acquire({ port })
194
+ await injector[Symbol.asyncDispose]()
195
+ // The in-flight promise either resolved (server opened before dispose)
196
+ // or rejected cleanly — both are acceptable; we just assert no process
197
+ // crash / unhandled rejection escaped the pool's `allSettled` guard.
198
+ await inflight.catch(() => undefined)
199
+ })
200
+
201
+ it('Should stop tracking sockets that close before dispose', async () => {
202
+ const injector = createInjector()
203
+ try {
204
+ const pool = injector.get(HttpServerPoolToken)
205
+ const port = getPort()
206
+ const record = await pool.acquire({ port })
207
+ record.apis.push(
208
+ makeApi(async ({ res }) => {
209
+ res.end('ok')
210
+ }),
211
+ )
212
+
213
+ await fetch(`http://127.0.0.1:${port}/ok`)
214
+ // Let the TCP socket actually flip to 'close' before we tear down.
215
+ await new Promise((r) => setTimeout(r, 50))
216
+ } finally {
217
+ await injector[Symbol.asyncDispose]()
218
+ }
219
+ })
220
+ })