@furystack/rest-service 10.0.28 → 10.1.1

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 (74) hide show
  1. package/README.md +249 -12
  2. package/esm/api-manager.d.ts +1 -3
  3. package/esm/api-manager.d.ts.map +1 -1
  4. package/esm/api-manager.js +4 -6
  5. package/esm/api-manager.js.map +1 -1
  6. package/esm/header-processor.d.ts +39 -0
  7. package/esm/header-processor.d.ts.map +1 -0
  8. package/esm/header-processor.js +113 -0
  9. package/esm/header-processor.js.map +1 -0
  10. package/esm/header-processor.spec.d.ts +2 -0
  11. package/esm/header-processor.spec.d.ts.map +1 -0
  12. package/esm/header-processor.spec.js +420 -0
  13. package/esm/header-processor.spec.js.map +1 -0
  14. package/esm/helpers.d.ts +69 -1
  15. package/esm/helpers.d.ts.map +1 -1
  16. package/esm/helpers.js +70 -1
  17. package/esm/helpers.js.map +1 -1
  18. package/esm/helpers.spec.js +21 -5
  19. package/esm/helpers.spec.js.map +1 -1
  20. package/esm/http-proxy-handler.d.ts +53 -0
  21. package/esm/http-proxy-handler.d.ts.map +1 -0
  22. package/esm/http-proxy-handler.js +179 -0
  23. package/esm/http-proxy-handler.js.map +1 -0
  24. package/esm/http-user-context.d.ts +4 -4
  25. package/esm/http-user-context.d.ts.map +1 -1
  26. package/esm/http-user-context.js +4 -4
  27. package/esm/http-user-context.js.map +1 -1
  28. package/esm/index.d.ts +1 -0
  29. package/esm/index.d.ts.map +1 -1
  30. package/esm/index.js +1 -0
  31. package/esm/index.js.map +1 -1
  32. package/esm/path-processor.d.ts +33 -0
  33. package/esm/path-processor.d.ts.map +1 -0
  34. package/esm/path-processor.js +58 -0
  35. package/esm/path-processor.js.map +1 -0
  36. package/esm/path-processor.spec.d.ts +2 -0
  37. package/esm/path-processor.spec.d.ts.map +1 -0
  38. package/esm/path-processor.spec.js +256 -0
  39. package/esm/path-processor.spec.js.map +1 -0
  40. package/esm/proxy-manager.d.ts +52 -0
  41. package/esm/proxy-manager.d.ts.map +1 -0
  42. package/esm/proxy-manager.js +84 -0
  43. package/esm/proxy-manager.js.map +1 -0
  44. package/esm/proxy-manager.spec.d.ts +2 -0
  45. package/esm/proxy-manager.spec.d.ts.map +1 -0
  46. package/esm/proxy-manager.spec.js +1781 -0
  47. package/esm/proxy-manager.spec.js.map +1 -0
  48. package/esm/server-manager.d.ts +7 -0
  49. package/esm/server-manager.d.ts.map +1 -1
  50. package/esm/server-manager.js +12 -0
  51. package/esm/server-manager.js.map +1 -1
  52. package/esm/static-server-manager.d.ts.map +1 -1
  53. package/esm/static-server-manager.js +5 -7
  54. package/esm/static-server-manager.js.map +1 -1
  55. package/esm/websocket-proxy-handler.d.ts +44 -0
  56. package/esm/websocket-proxy-handler.d.ts.map +1 -0
  57. package/esm/websocket-proxy-handler.js +157 -0
  58. package/esm/websocket-proxy-handler.js.map +1 -0
  59. package/package.json +12 -10
  60. package/src/api-manager.ts +5 -15
  61. package/src/header-processor.spec.ts +514 -0
  62. package/src/header-processor.ts +140 -0
  63. package/src/helpers.spec.ts +23 -5
  64. package/src/helpers.ts +72 -1
  65. package/src/http-proxy-handler.ts +215 -0
  66. package/src/http-user-context.ts +6 -6
  67. package/src/index.ts +1 -0
  68. package/src/path-processor.spec.ts +318 -0
  69. package/src/path-processor.ts +69 -0
  70. package/src/proxy-manager.spec.ts +2094 -0
  71. package/src/proxy-manager.ts +101 -0
  72. package/src/server-manager.ts +19 -0
  73. package/src/static-server-manager.ts +5 -7
  74. package/src/websocket-proxy-handler.ts +204 -0
package/src/helpers.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { User } from '@furystack/core'
2
2
  import type { Injector } from '@furystack/inject'
3
- import { HttpAuthenticationSettings } from './http-authentication-settings.js'
4
3
  import type { RestApi } from '@furystack/rest'
5
4
  import type { ImplementApiOptions } from './api-manager.js'
6
5
  import { ApiManager } from './api-manager.js'
6
+ import { HttpAuthenticationSettings } from './http-authentication-settings.js'
7
7
  import type { DefaultSession } from './models/default-session.js'
8
+ import type { ProxyOptions } from './proxy-manager.js'
9
+ import { ProxyManager } from './proxy-manager.js'
8
10
  import type { StaticServerOptions } from './static-server-manager.js'
9
11
  import { StaticServerManager } from './static-server-manager.js'
10
12
 
@@ -38,3 +40,72 @@ export const useStaticFiles = (options: { injector: Injector } & StaticServerOpt
38
40
  const { injector, ...settings } = options
39
41
  return injector.getInstance(StaticServerManager).addStaticSite(settings)
40
42
  }
43
+
44
+ /**
45
+ * Sets up a proxy server that forwards HTTP requests from a source URL to a target URL.
46
+ *
47
+ * The proxy acts as an intermediary, forwarding requests and responses while allowing
48
+ * transformation of headers, cookies, and paths. It returns 502 Bad Gateway on errors
49
+ * and emits 'onProxyFailed' events for monitoring.
50
+ *
51
+ * WebSocket connections can also be proxied by setting `enableWebsockets: true`, allowing
52
+ * bidirectional real-time communication through the proxy.
53
+ *
54
+ * @param options The settings for the proxy server
55
+ * @param options.injector The Injector instance
56
+ * @param options.sourceBaseUrl The base URL path to match for proxying (e.g., '/api', '/old').
57
+ * Can be specified with or without a trailing slash.
58
+ * @param options.targetBaseUrl The target server URL (must be a valid HTTP/HTTPS URL)
59
+ * @param options.pathRewrite Optional function to rewrite the path before forwarding.
60
+ * Receives the path after sourceBaseUrl, including leading slash and query string.
61
+ * Example: for 'GET /api/users?active=true' with sourceBaseUrl='/api',
62
+ * pathRewrite receives '/users?active=true'
63
+ * @param options.sourceHostName The hostname for the source server (optional, defaults to all interfaces)
64
+ * @param options.sourcePort The port for the source server
65
+ * @param options.headers Optional function to transform request headers.
66
+ * **Note**: Receives headers AFTER filtering hop-by-hop headers
67
+ * (Connection, Keep-Alive, Transfer-Encoding, Upgrade, etc.) for security
68
+ * and protocol compliance. The proxy automatically adds X-Forwarded-* headers.
69
+ * This transformation applies to both HTTP and WebSocket requests.
70
+ * @param options.cookies Optional function to transform request cookies (array of cookie strings)
71
+ * @param options.responseCookies Optional function to transform response Set-Cookie headers
72
+ * @param options.timeout Optional timeout in milliseconds for proxy requests (default: 30000).
73
+ * If exceeded, the request is aborted and 502 is returned.
74
+ * Applies to both HTTP and WebSocket upgrade requests.
75
+ * @param options.enableWebsockets Optional flag to enable WebSocket proxying (default: false).
76
+ * When enabled, WebSocket upgrade requests will be forwarded to the target.
77
+ * @returns a promise that resolves when the proxy is set up
78
+ * @example
79
+ * ```ts
80
+ * // Basic HTTP proxy with timeout
81
+ * await useProxy({
82
+ * injector,
83
+ * sourceBaseUrl: '/api',
84
+ * targetBaseUrl: 'https://api.example.com',
85
+ * sourcePort: 3000,
86
+ * timeout: 5000,
87
+ * })
88
+ *
89
+ * // Proxy with WebSocket support
90
+ * await useProxy({
91
+ * injector,
92
+ * sourceBaseUrl: '/ws',
93
+ * targetBaseUrl: 'https://ws.example.com',
94
+ * sourcePort: 3000,
95
+ * enableWebsockets: true,
96
+ * })
97
+ *
98
+ * // Proxy with error monitoring (HTTP and WebSocket)
99
+ * const proxyManager = injector.getInstance(ProxyManager)
100
+ * proxyManager.subscribe('onProxyFailed', ({ from, to, error }) => {
101
+ * console.error(`HTTP Proxy failed: ${from} -> ${to}`, error)
102
+ * })
103
+ * proxyManager.subscribe('onWebSocketProxyFailed', ({ from, to, error }) => {
104
+ * console.error(`WebSocket Proxy failed: ${from} -> ${to}`, error)
105
+ * })
106
+ * ```
107
+ */
108
+ export const useProxy = (options: { injector: Injector } & ProxyOptions) => {
109
+ const { injector, ...settings } = options
110
+ return injector.getInstance(ProxyManager).addProxy(settings)
111
+ }
@@ -0,0 +1,215 @@
1
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
2
+ import { Readable } from 'stream'
3
+ import { HeaderProcessor } from './header-processor.js'
4
+ import { PathProcessor } from './path-processor.js'
5
+
6
+ export interface HttpProxyOptions {
7
+ sourceBaseUrl: string
8
+ targetBaseUrl: string
9
+ pathRewrite?: (sourcePath: string) => string
10
+ headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
11
+ cookies?: (originalCookies: string[]) => string[]
12
+ responseCookies?: (responseCookies: string[]) => string[]
13
+ timeout?: number
14
+ onError?: (error: { from: string; to: string; error: unknown }) => void
15
+ }
16
+
17
+ /**
18
+ * Handles HTTP request proxying with streaming support
19
+ */
20
+ export class HttpProxyHandler {
21
+ private readonly headerProcessor = new HeaderProcessor()
22
+ private readonly pathProcessor = new PathProcessor()
23
+
24
+ constructor(private readonly options: HttpProxyOptions) {}
25
+
26
+ /**
27
+ * Sets up abort controller and timeout for request cancellation
28
+ */
29
+ private setupAbortHandling(
30
+ req: IncomingMessage,
31
+ res: ServerResponse,
32
+ timeoutMs: number,
33
+ ): { abortController: AbortController; timeoutId: NodeJS.Timeout } {
34
+ const abortController = new AbortController()
35
+
36
+ const abortUpstream = () => {
37
+ try {
38
+ abortController.abort()
39
+ } catch {
40
+ // Ignore abort errors
41
+ }
42
+ // Clean up listeners to prevent memory leaks
43
+ res.off('close', abortUpstream)
44
+ req.off('aborted', abortUpstream)
45
+ }
46
+
47
+ res.once('close', abortUpstream)
48
+ req.once('aborted', abortUpstream)
49
+
50
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs)
51
+
52
+ return { abortController, timeoutId }
53
+ }
54
+
55
+ /**
56
+ * Extracts Set-Cookie headers from the response, handling both standard and undici formats
57
+ */
58
+ private extractSetCookieHeaders(proxyResponse: Response): string[] {
59
+ const setCookieHeaders: string[] = []
60
+ const anyHeaders = proxyResponse.headers as unknown as { getSetCookie?: () => string[] }
61
+ const fromGetter = anyHeaders.getSetCookie?.()
62
+
63
+ if (fromGetter && Array.isArray(fromGetter) && fromGetter.length) {
64
+ setCookieHeaders.push(...fromGetter)
65
+ } else {
66
+ proxyResponse.headers.forEach((value, key) => {
67
+ if (key.toLowerCase() === 'set-cookie') {
68
+ setCookieHeaders.push(value)
69
+ }
70
+ })
71
+ }
72
+
73
+ return setCookieHeaders
74
+ }
75
+
76
+ /**
77
+ * Copies response headers from proxy response to client response
78
+ */
79
+ private copyResponseHeaders(proxyResponse: Response, res: ServerResponse): void {
80
+ proxyResponse.headers.forEach((value, key) => {
81
+ if (!this.headerProcessor.isHopByHopHeader(key)) {
82
+ if (key.toLowerCase() !== 'set-cookie') {
83
+ res.setHeader(key, value)
84
+ }
85
+ }
86
+ })
87
+ }
88
+
89
+ /**
90
+ * Handles Set-Cookie headers with optional transformation
91
+ */
92
+ private handleSetCookieHeaders(setCookieHeaders: string[], res: ServerResponse): void {
93
+ if (setCookieHeaders.length > 0) {
94
+ const finalSetCookies = this.options.responseCookies
95
+ ? this.options.responseCookies(setCookieHeaders)
96
+ : setCookieHeaders
97
+ res.setHeader('set-cookie', finalSetCookies)
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Streams the response body from proxy to client
103
+ */
104
+ private async streamResponseBody(proxyResponse: Response, res: ServerResponse): Promise<void> {
105
+ if (!proxyResponse.body) {
106
+ res.end()
107
+ return
108
+ }
109
+
110
+ const reader = proxyResponse.body.getReader()
111
+ try {
112
+ while (true) {
113
+ const { done, value } = await reader.read()
114
+ if (done) break
115
+ if (!res.write(value)) {
116
+ await new Promise((resolve) => res.once('drain', resolve))
117
+ }
118
+ }
119
+ res.end()
120
+ } catch (error) {
121
+ try {
122
+ await reader.cancel()
123
+ } catch {
124
+ // Ignore cancel errors
125
+ }
126
+ if (!res.destroyed) {
127
+ res.destroy()
128
+ }
129
+ throw error
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Handles proxy errors and sends appropriate response to client
135
+ */
136
+ private handleProxyError(error: unknown, res: ServerResponse): void {
137
+ if (this.options.onError) {
138
+ this.options.onError({
139
+ from: this.options.sourceBaseUrl,
140
+ to: this.options.targetBaseUrl,
141
+ error,
142
+ })
143
+ }
144
+
145
+ if (!res.headersSent) {
146
+ res.writeHead(502, { 'Content-Type': 'text/plain' })
147
+ res.end('Bad Gateway')
148
+ } else if (!res.destroyed) {
149
+ res.destroy()
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Main handler for proxying HTTP requests
155
+ */
156
+ public async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
157
+ try {
158
+ // Build target URL
159
+ const targetUrl = this.pathProcessor.processUrl(
160
+ req.url as string,
161
+ this.options.sourceBaseUrl,
162
+ this.options.targetBaseUrl,
163
+ this.options.pathRewrite,
164
+ )
165
+
166
+ const parsedTargetUrl = new URL(targetUrl)
167
+
168
+ // Process headers
169
+ const { proxyHeaders } = this.headerProcessor.processRequestHeaders(req, parsedTargetUrl.host, {
170
+ headers: this.options.headers,
171
+ cookies: this.options.cookies,
172
+ })
173
+
174
+ // Set up timeout and abort handling
175
+ const timeoutMs = this.options.timeout ?? 30000
176
+ const { abortController, timeoutId } = this.setupAbortHandling(req, res, timeoutMs)
177
+
178
+ try {
179
+ // Make the proxy request
180
+ const proxyResponse = await fetch(targetUrl, {
181
+ method: req.method,
182
+ headers: proxyHeaders,
183
+ body:
184
+ req.method !== 'GET' && req.method !== 'HEAD'
185
+ ? (Readable.toWeb(req) as ReadableStream<Uint8Array>)
186
+ : undefined,
187
+ // @ts-expect-error - duplex is not in the types yet, but required for streaming bodies
188
+ duplex: 'half',
189
+ signal: abortController.signal,
190
+ })
191
+ clearTimeout(timeoutId)
192
+
193
+ // Extract and handle Set-Cookie headers
194
+ const setCookieHeaders = this.extractSetCookieHeaders(proxyResponse)
195
+
196
+ // Copy other response headers
197
+ this.copyResponseHeaders(proxyResponse, res)
198
+
199
+ // Handle Set-Cookie headers with transformation
200
+ this.handleSetCookieHeaders(setCookieHeaders, res)
201
+
202
+ // Set status code
203
+ res.writeHead(proxyResponse.status, res.getHeaders())
204
+
205
+ // Stream the response body
206
+ await this.streamResponseBody(proxyResponse, res)
207
+ } catch (error) {
208
+ clearTimeout(timeoutId)
209
+ throw error
210
+ }
211
+ } catch (error) {
212
+ this.handleProxyError(error, res)
213
+ }
214
+ }
215
+ }
@@ -1,11 +1,11 @@
1
- import type { IncomingMessage } from 'http'
2
1
  import type { User } from '@furystack/core'
3
2
  import { StoreManager } from '@furystack/core'
4
3
  import { Injectable, Injected } from '@furystack/inject'
5
- import { HttpAuthenticationSettings } from './http-authentication-settings.js'
6
- import type { DefaultSession } from './models/default-session.js'
7
4
  import { PasswordAuthenticator, UnauthenticatedError } from '@furystack/security'
8
5
  import { randomBytes } from 'crypto'
6
+ import type { IncomingMessage } from 'http'
7
+ import { HttpAuthenticationSettings } from './http-authentication-settings.js'
8
+ import type { DefaultSession } from './models/default-session.js'
9
9
 
10
10
  /**
11
11
  * Injectable UserContext for FuryStack HTTP Api
@@ -38,7 +38,7 @@ export class HttpUserContext {
38
38
 
39
39
  /**
40
40
  * @param request The request to be authenticated
41
- * @returns if the current user is authenticated
41
+ * @returns whether the current user is authenticated
42
42
  */
43
43
  public async isAuthenticated(request: IncomingMessage) {
44
44
  try {
@@ -50,10 +50,10 @@ export class HttpUserContext {
50
50
  }
51
51
 
52
52
  /**
53
- * Returns if the current user can be authorized with ALL of the specified roles
53
+ * Returns whether the current user can be authorized with ALL of the specified roles
54
54
  * @param request The request to be authenticated
55
55
  * @param roles The list of roles to authorize
56
- * @returns a boolean value that indicates if the user is authenticated
56
+ * @returns a boolean value that indicates whether the user is authorized
57
57
  */
58
58
  public async isAuthorized(request: IncomingMessage, ...roles: string[]): Promise<boolean> {
59
59
  const currentUser = await this.getCurrentUser(request)
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from './http-authentication-settings.js'
10
10
  export * from './http-user-context.js'
11
11
  export * from './mime-types.js'
12
12
  export * from './models/index.js'
13
+ export * from './proxy-manager.js'
13
14
  export * from './read-post-body.js'
14
15
  export * from './request-action-implementation.js'
15
16
  export * from './schema-validator/index.js'
@@ -0,0 +1,318 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { PathProcessor } from './path-processor.js'
3
+
4
+ describe('PathProcessor', () => {
5
+ const processor = new PathProcessor()
6
+
7
+ describe('validateUrl', () => {
8
+ it('should validate and return a valid URL', () => {
9
+ const url = processor.validateUrl('http://example.com/path')
10
+ expect(url).toBeInstanceOf(URL)
11
+ expect(url.href).toBe('http://example.com/path')
12
+ })
13
+
14
+ it('should throw error for invalid URL', () => {
15
+ expect(() => processor.validateUrl('not-a-valid-url')).toThrow('Invalid URL')
16
+ })
17
+
18
+ it('should include context in error message', () => {
19
+ expect(() => processor.validateUrl('invalid', 'targetBaseUrl')).toThrow('Invalid targetBaseUrl')
20
+ })
21
+
22
+ it('should validate HTTPS URLs', () => {
23
+ const url = processor.validateUrl('https://secure.example.com')
24
+ expect(url.protocol).toBe('https:')
25
+ })
26
+
27
+ it('should validate URLs with query strings', () => {
28
+ const url = processor.validateUrl('http://example.com/path?foo=bar')
29
+ expect(url.search).toBe('?foo=bar')
30
+ })
31
+ })
32
+
33
+ describe('validateHttpProtocol', () => {
34
+ it('should accept HTTP protocol', () => {
35
+ const url = new URL('http://example.com')
36
+ expect(() => processor.validateHttpProtocol(url)).not.toThrow()
37
+ })
38
+
39
+ it('should accept HTTPS protocol', () => {
40
+ const url = new URL('https://example.com')
41
+ expect(() => processor.validateHttpProtocol(url)).not.toThrow()
42
+ })
43
+
44
+ it('should reject FTP protocol', () => {
45
+ const url = new URL('ftp://example.com')
46
+ expect(() => processor.validateHttpProtocol(url)).toThrow('Invalid targetBaseUrl protocol: ftp:')
47
+ })
48
+
49
+ it('should reject WS protocol', () => {
50
+ const url = new URL('ws://example.com')
51
+ expect(() => processor.validateHttpProtocol(url)).toThrow('Invalid targetBaseUrl protocol: ws:')
52
+ })
53
+
54
+ it('should reject file protocol', () => {
55
+ const url = new URL('file:///path/to/file')
56
+ expect(() => processor.validateHttpProtocol(url)).toThrow('Invalid targetBaseUrl protocol: file:')
57
+ })
58
+ })
59
+
60
+ describe('extractSourcePath', () => {
61
+ it('should extract path after source base URL', () => {
62
+ const path = processor.extractSourcePath('/api/users/123', '/api')
63
+ expect(path).toBe('/users/123')
64
+ })
65
+
66
+ it('should extract empty path when URL matches exactly', () => {
67
+ const path = processor.extractSourcePath('/api', '/api')
68
+ expect(path).toBe('')
69
+ })
70
+
71
+ it('should preserve query strings', () => {
72
+ const path = processor.extractSourcePath('/api/users?page=1&limit=10', '/api')
73
+ expect(path).toBe('/users?page=1&limit=10')
74
+ })
75
+
76
+ it('should handle source base URL with trailing slash', () => {
77
+ const path = processor.extractSourcePath('/api/users/123', '/api/')
78
+ // PathHelper.extractPath now consistently returns paths with leading slashes
79
+ expect(path).toBe('/users/123')
80
+ })
81
+ })
82
+
83
+ describe('applyPathRewrite', () => {
84
+ it('should return path as-is when no rewrite function provided', () => {
85
+ const path = processor.applyPathRewrite('/users/123')
86
+ expect(path).toBe('/users/123')
87
+ })
88
+
89
+ it('should apply rewrite function when provided', () => {
90
+ const rewrite = (path: string) => path.replace('/old', '/new')
91
+ const path = processor.applyPathRewrite('/old/path', rewrite)
92
+ expect(path).toBe('/new/path')
93
+ })
94
+
95
+ it('should handle complex rewrite logic', () => {
96
+ const rewrite = (path: string) => {
97
+ // Remove version prefix
98
+ return path.replace(/^\/v\d+/, '')
99
+ }
100
+ const path = processor.applyPathRewrite('/v1/users/123', rewrite)
101
+ expect(path).toBe('/users/123')
102
+ })
103
+
104
+ it('should allow complete path transformation', () => {
105
+ const rewrite = () => '/completely/different/path'
106
+ const path = processor.applyPathRewrite('/original', rewrite)
107
+ expect(path).toBe('/completely/different/path')
108
+ })
109
+ })
110
+
111
+ describe('buildTargetUrl', () => {
112
+ it('should combine base URL and path', () => {
113
+ const url = processor.buildTargetUrl('http://example.com', '/api/users')
114
+ expect(url).toBe('http://example.com/api/users')
115
+ })
116
+
117
+ it('should handle base URL with trailing slash', () => {
118
+ const url = processor.buildTargetUrl('http://example.com/', '/api/users')
119
+ // PathHelper.joinUrl now correctly handles trailing slashes, avoiding double slashes
120
+ expect(url).toBe('http://example.com/api/users')
121
+ })
122
+
123
+ it('should preserve query strings in path', () => {
124
+ const url = processor.buildTargetUrl('http://example.com', '/api/users?page=1')
125
+ expect(url).toBe('http://example.com/api/users?page=1')
126
+ })
127
+
128
+ it('should handle empty path', () => {
129
+ const url = processor.buildTargetUrl('http://example.com', '')
130
+ expect(url).toBe('http://example.com')
131
+ })
132
+ })
133
+
134
+ describe('processUrl', () => {
135
+ it('should process complete URL transformation', () => {
136
+ const url = processor.processUrl('/api/users/123', '/api', 'http://target.com')
137
+ expect(url).toBe('http://target.com/users/123')
138
+ })
139
+
140
+ it('should apply path rewrite during processing', () => {
141
+ const rewrite = (path: string) => path.replace('/old', '/new')
142
+ const url = processor.processUrl('/api/old/path', '/api', 'http://target.com', rewrite)
143
+ expect(url).toBe('http://target.com/new/path')
144
+ })
145
+
146
+ it('should preserve query strings', () => {
147
+ const url = processor.processUrl('/api/users?page=1&limit=10', '/api', 'http://target.com')
148
+ expect(url).toBe('http://target.com/users?page=1&limit=10')
149
+ })
150
+
151
+ it('should throw error for invalid resulting URL', () => {
152
+ // Use a base URL that when combined with the rewritten path will be invalid
153
+ const rewrite = () => '://invalid-path'
154
+ expect(() => processor.processUrl('/api/test', '/api', 'http:', rewrite)).toThrow('Invalid')
155
+ })
156
+
157
+ it('should handle complex source URLs', () => {
158
+ const url = processor.processUrl(
159
+ '/proxy/v1/api/users/123?sort=name&order=asc',
160
+ '/proxy',
161
+ 'http://backend.example.com:8080',
162
+ )
163
+ expect(url).toBe('http://backend.example.com:8080/v1/api/users/123?sort=name&order=asc')
164
+ })
165
+
166
+ it('should work with HTTPS target URLs', () => {
167
+ const url = processor.processUrl('/api/secure', '/api', 'https://secure.example.com')
168
+ expect(url).toBe('https://secure.example.com/secure')
169
+ })
170
+
171
+ it('should handle exact URL matches', () => {
172
+ const url = processor.processUrl('/api', '/api', 'http://target.com')
173
+ expect(url).toBe('http://target.com')
174
+ })
175
+
176
+ it('should validate the resulting target URL', () => {
177
+ // This should not throw because the result is valid
178
+ expect(() => processor.processUrl('/api/test', '/api', 'http://target.com')).not.toThrow()
179
+ })
180
+ })
181
+
182
+ describe('Edge cases and complex scenarios', () => {
183
+ it('should handle very long URLs', () => {
184
+ const longPath = `/api${'/segment'.repeat(100)}`
185
+ const url = processor.processUrl(longPath, '/api', 'http://target.com')
186
+ expect(url).toContain('target.com')
187
+ expect(url).toContain('/segment')
188
+ })
189
+
190
+ it('should handle URLs with special characters in path', () => {
191
+ const url = processor.processUrl('/api/path%20with%20spaces', '/api', 'http://target.com')
192
+ expect(url).toBe('http://target.com/path%20with%20spaces')
193
+ })
194
+
195
+ it('should handle URLs with encoded special characters', () => {
196
+ const url = processor.processUrl('/api/user%2Fname', '/api', 'http://target.com')
197
+ expect(url).toBe('http://target.com/user%2Fname')
198
+ })
199
+
200
+ it('should handle URLs with hash fragments', () => {
201
+ const url = processor.processUrl('/api/resource#section', '/api', 'http://target.com')
202
+ expect(url).toBe('http://target.com/resource#section')
203
+ })
204
+
205
+ it('should handle multiple query parameters', () => {
206
+ const url = processor.processUrl('/api/search?q=test&sort=date&order=desc&page=1', '/api', 'http://target.com')
207
+ expect(url).toBe('http://target.com/search?q=test&sort=date&order=desc&page=1')
208
+ })
209
+
210
+ it('should handle empty query parameter values', () => {
211
+ const url = processor.processUrl('/api/test?empty=&another=value', '/api', 'http://target.com')
212
+ expect(url).toBe('http://target.com/test?empty=&another=value')
213
+ })
214
+
215
+ it('should handle source base URL ending with slash and path starting with slash', () => {
216
+ const path = processor.extractSourcePath('/api//users', '/api/')
217
+ // PathHelper.extractPath preserves the double slash from the input URL
218
+ // If normalization is needed, it should be done on the input URL first
219
+ expect(path).toBe('//users')
220
+ })
221
+
222
+ it('should handle double slashes in paths', () => {
223
+ const url = processor.buildTargetUrl('http://target.com', '//path//to//resource')
224
+ // PathHelper.joinUrl preserves the double slash at the start since it's already there
225
+ expect(url).toBe('http://target.com//path//to//resource')
226
+ })
227
+
228
+ it('should handle target URLs with ports', () => {
229
+ const url = processor.processUrl('/api/test', '/api', 'http://target.com:8080')
230
+ expect(url).toBe('http://target.com:8080/test')
231
+ })
232
+
233
+ it('should handle target URLs with paths', () => {
234
+ const url = processor.processUrl('/api/test', '/api', 'http://target.com/base')
235
+ expect(url).toBe('http://target.com/base/test')
236
+ })
237
+
238
+ it('should validate URL with only protocol', () => {
239
+ expect(() => processor.validateUrl('http://')).toThrow('Invalid URL')
240
+ })
241
+
242
+ it('should handle path rewrite that adds query parameters', () => {
243
+ const rewrite = (path: string) => `${path}?added=param`
244
+ const url = processor.processUrl('/api/test?existing=value', '/api', 'http://target.com', rewrite)
245
+ expect(url).toBe('http://target.com/test?existing=value?added=param')
246
+ })
247
+
248
+ it('should handle path rewrite that removes path completely', () => {
249
+ const rewrite = () => ''
250
+ const url = processor.processUrl('/api/test', '/api', 'http://target.com', rewrite)
251
+ expect(url).toBe('http://target.com')
252
+ })
253
+
254
+ it('should handle path rewrite that adds leading slash', () => {
255
+ const rewrite = (path: string) => `/${path}`
256
+ const url = processor.processUrl('/api/test', '/api', 'http://target.com', rewrite)
257
+ expect(url).toBe('http://target.com//test')
258
+ })
259
+
260
+ it('should validate HTTPS URLs with authentication', () => {
261
+ const url = processor.validateUrl('https://user:pass@example.com/path')
262
+ expect(url.protocol).toBe('https:')
263
+ expect(url.username).toBe('user')
264
+ expect(url.password).toBe('pass')
265
+ })
266
+
267
+ it('should handle URLs with international domain names', () => {
268
+ const url = processor.validateUrl('http://例え.jp/path')
269
+ expect(url.hostname).toBeTruthy()
270
+ })
271
+
272
+ it('should handle target URLs with trailing slash and path without leading slash', () => {
273
+ const url = processor.buildTargetUrl('http://target.com/', 'path')
274
+ expect(url).toBe('http://target.com/path')
275
+ })
276
+
277
+ it('should extract path with multiple slashes in source base URL', () => {
278
+ const path = processor.extractSourcePath('/api//users', '/api')
279
+ expect(path).toBe('//users')
280
+ })
281
+
282
+ it('should handle complex path rewrite with regex', () => {
283
+ const rewrite = (path: string) => path.replace(/\/v\d+\//, '/')
284
+ const url = processor.processUrl('/api/v2/users', '/api', 'http://target.com', rewrite)
285
+ expect(url).toBe('http://target.com/users')
286
+ })
287
+
288
+ it('should validate data URLs are rejected', () => {
289
+ expect(() => processor.validateHttpProtocol(new URL('data:text/plain,hello'))).toThrow(
290
+ 'Invalid targetBaseUrl protocol',
291
+ )
292
+ })
293
+
294
+ it('should validate mailto URLs are rejected', () => {
295
+ expect(() => processor.validateHttpProtocol(new URL('mailto:test@example.com'))).toThrow(
296
+ 'Invalid targetBaseUrl protocol',
297
+ )
298
+ })
299
+
300
+ it('should handle IPv4 addresses in URLs', () => {
301
+ const url = processor.validateUrl('http://192.168.1.1:8080/path')
302
+ expect(url.hostname).toBe('192.168.1.1')
303
+ expect(url.port).toBe('8080')
304
+ })
305
+
306
+ it('should handle IPv6 addresses in URLs', () => {
307
+ const url = processor.validateUrl('http://[::1]:8080/path')
308
+ expect(url.hostname).toBe('[::1]')
309
+ expect(url.port).toBe('8080')
310
+ })
311
+
312
+ it('should handle localhost in URLs', () => {
313
+ const url = processor.validateUrl('http://localhost:3000/api')
314
+ expect(url.hostname).toBe('localhost')
315
+ expect(url.port).toBe('3000')
316
+ })
317
+ })
318
+ })