@furystack/rest-service 10.0.27 → 10.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 (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
@@ -0,0 +1,2094 @@
1
+ import { getPort } from '@furystack/core/port-generator'
2
+ import { Injector } from '@furystack/inject'
3
+ import { usingAsync } from '@furystack/utils'
4
+ import { mkdirSync, writeFileSync } from 'fs'
5
+ import type { IncomingMessage, OutgoingHttpHeaders, Server, ServerResponse } from 'http'
6
+ import { createServer as createHttpServer } from 'http'
7
+ import { tmpdir } from 'os'
8
+ import { join } from 'path'
9
+ import { beforeAll, describe, expect, it } from 'vitest'
10
+ import type { RawData } from 'ws'
11
+ import { WebSocket, WebSocketServer } from 'ws'
12
+ import { ProxyManager } from './proxy-manager.js'
13
+ import { StaticServerManager } from './static-server-manager.js'
14
+
15
+ describe('ProxyManager', () => {
16
+ // Create a temporary directory for test files
17
+ const testDir = join(tmpdir(), 'furystack-proxy-test')
18
+
19
+ beforeAll(() => {
20
+ try {
21
+ mkdirSync(testDir, { recursive: true })
22
+ } catch (error) {
23
+ // Directory might already exist
24
+ }
25
+ })
26
+
27
+ // Helper function to convert RawData to string
28
+ const rawDataToString = (data: RawData): string =>
29
+ Buffer.isBuffer(data)
30
+ ? data.toString('utf8')
31
+ : Array.isArray(data)
32
+ ? Buffer.concat(data).toString('utf8')
33
+ : Buffer.from(data).toString('utf8')
34
+
35
+ // Helper function to create a simple echo server
36
+ const createEchoServer = (
37
+ port: number,
38
+ handler?: (req: IncomingMessage, res: ServerResponse) => void,
39
+ ): Promise<Server> => {
40
+ const server = createHttpServer(
41
+ handler ||
42
+ ((req, res) => {
43
+ res.writeHead(200, { 'Content-Type': 'application/json' })
44
+ res.end(JSON.stringify({ url: req.url, method: req.method }))
45
+ }),
46
+ )
47
+ return new Promise((resolve) => {
48
+ server.listen(port, () => resolve(server))
49
+ })
50
+ }
51
+
52
+ // Helper function to create a WebSocket test server
53
+ const createWsTestServer = (
54
+ port: number,
55
+ onConnection: (ws: WebSocket, req: IncomingMessage) => void,
56
+ ): Promise<{ server: Server; wss: WebSocketServer }> => {
57
+ const server = createHttpServer()
58
+ const wss = new WebSocketServer({ server })
59
+ wss.on('connection', onConnection)
60
+ return new Promise((resolve) => {
61
+ server.listen(port, () => resolve({ server, wss }))
62
+ })
63
+ }
64
+
65
+ describe('Basic proxy functionality', () => {
66
+ it('Should proxy requests from source URL to target URL', async () => {
67
+ await usingAsync(new Injector(), async (injector) => {
68
+ const proxyManager = injector.getInstance(ProxyManager)
69
+ const staticServerManager = injector.getInstance(StaticServerManager)
70
+
71
+ const proxyPort = getPort()
72
+ const targetPort = getPort()
73
+
74
+ // Create a test file for the target server
75
+ const testFile = join(testDir, 'test.json')
76
+ writeFileSync(
77
+ testFile,
78
+ JSON.stringify({
79
+ message: 'Hello from target server',
80
+ url: '/test',
81
+ timestamp: Date.now(),
82
+ }),
83
+ )
84
+
85
+ // Set up target server (static file server) - serve files from testDir
86
+ await staticServerManager.addStaticSite({
87
+ baseUrl: '/',
88
+ path: testDir,
89
+ port: targetPort,
90
+ })
91
+
92
+ // Set up proxy server
93
+ await proxyManager.addProxy({
94
+ sourceBaseUrl: '/old',
95
+ targetBaseUrl: `http://localhost:${targetPort}`,
96
+ pathRewrite: (path) => path.replace('/path', ''),
97
+ sourcePort: proxyPort,
98
+ })
99
+
100
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/old/path/test.json`)
101
+
102
+ expect(result.status).toBe(200)
103
+ expect(result.url).toBe(`http://127.0.0.1:${proxyPort}/old/path/test.json`)
104
+
105
+ const responseData = (await result.json()) as { message: string; url: string; timestamp: number }
106
+ expect(responseData.message).toBe('Hello from target server')
107
+ expect(responseData.url).toBe('/test')
108
+ })
109
+ })
110
+
111
+ it('Should preserve query strings when proxying', async () => {
112
+ await usingAsync(new Injector(), async (injector) => {
113
+ const proxyManager = injector.getInstance(ProxyManager)
114
+ const targetPort = getPort()
115
+ const proxyPort = getPort()
116
+
117
+ // Create a simple echo server that returns query parameters
118
+ const targetServer = createHttpServer((req, res) => {
119
+ res.writeHead(200, { 'Content-Type': 'application/json' })
120
+ res.end(JSON.stringify({ url: req.url, query: req.url?.split('?')[1] || '' }))
121
+ })
122
+
123
+ await new Promise<void>((resolve) => {
124
+ targetServer.listen(targetPort, () => resolve())
125
+ })
126
+
127
+ try {
128
+ await proxyManager.addProxy({
129
+ sourceBaseUrl: '/api',
130
+ targetBaseUrl: `http://localhost:${targetPort}`,
131
+ sourcePort: proxyPort,
132
+ })
133
+
134
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test?foo=bar&baz=qux`)
135
+ expect(result.status).toBe(200)
136
+
137
+ const data = (await result.json()) as { url: string; query: string }
138
+ expect(data.query).toBe('foo=bar&baz=qux')
139
+ } finally {
140
+ targetServer.close()
141
+ }
142
+ })
143
+ })
144
+
145
+ it('Should handle header transformation', async () => {
146
+ await usingAsync(new Injector(), async (injector) => {
147
+ const proxyManager = injector.getInstance(ProxyManager)
148
+ const staticServerManager = injector.getInstance(StaticServerManager)
149
+
150
+ const proxyPort = getPort()
151
+ const targetPort = getPort()
152
+
153
+ // Create a test file that returns headers
154
+ const headersFile = join(testDir, 'headers.json')
155
+ writeFileSync(
156
+ headersFile,
157
+ JSON.stringify({
158
+ message: 'Headers test',
159
+ receivedHeaders: '{{HEADERS}}', // Placeholder for dynamic content
160
+ }),
161
+ )
162
+
163
+ // Set up target server
164
+ await staticServerManager.addStaticSite({
165
+ baseUrl: '/',
166
+ path: testDir,
167
+ port: targetPort,
168
+ })
169
+
170
+ // Set up proxy server with header transformation
171
+ await proxyManager.addProxy({
172
+ sourceBaseUrl: '/old',
173
+ targetBaseUrl: `http://localhost:${targetPort}`,
174
+ pathRewrite: (path) => path.replace('/path', ''),
175
+ sourcePort: proxyPort,
176
+ headers: () => ({
177
+ 'X-Custom-Header': 'custom-value',
178
+ Authorization: 'Bearer new-token',
179
+ }),
180
+ })
181
+
182
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/old/path/headers.json`, {
183
+ headers: {
184
+ Authorization: 'Bearer old-token',
185
+ 'User-Agent': 'test-agent',
186
+ },
187
+ })
188
+
189
+ expect(result.status).toBe(200)
190
+ const responseData = (await result.json()) as { message: string; receivedHeaders: string }
191
+ expect(responseData.message).toBe('Headers test')
192
+ })
193
+ })
194
+
195
+ it('Should handle cookie transformation', async () => {
196
+ await usingAsync(new Injector(), async (injector) => {
197
+ const proxyManager = injector.getInstance(ProxyManager)
198
+ const staticServerManager = injector.getInstance(StaticServerManager)
199
+
200
+ const proxyPort = getPort()
201
+ const targetPort = getPort()
202
+
203
+ // Create a test file for cookies
204
+ const cookiesFile = join(testDir, 'cookies.json')
205
+ writeFileSync(
206
+ cookiesFile,
207
+ JSON.stringify({
208
+ message: 'Cookies test',
209
+ receivedCookies: '{{COOKIES}}', // Placeholder for dynamic content
210
+ }),
211
+ )
212
+
213
+ // Set up target server
214
+ await staticServerManager.addStaticSite({
215
+ baseUrl: '/',
216
+ path: testDir,
217
+ port: targetPort,
218
+ })
219
+
220
+ // Set up proxy server with cookie transformation
221
+ await proxyManager.addProxy({
222
+ sourceBaseUrl: '/old',
223
+ targetBaseUrl: `http://localhost:${targetPort}`,
224
+ pathRewrite: (path) => path.replace('/path', ''),
225
+ sourcePort: proxyPort,
226
+ cookies: (originalCookies) => [...originalCookies, 'newCookie=newValue', 'sessionId=updated-session'],
227
+ })
228
+
229
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/old/path/cookies.json`, {
230
+ headers: {
231
+ Cookie: 'oldCookie=oldValue; sessionId=old-session',
232
+ },
233
+ })
234
+
235
+ expect(result.status).toBe(200)
236
+ const responseData = (await result.json()) as { message: string; receivedCookies: string }
237
+ expect(responseData.message).toBe('Cookies test')
238
+ })
239
+ })
240
+
241
+ it('Should handle POST requests with JSON body', async () => {
242
+ await usingAsync(new Injector(), async (injector) => {
243
+ const proxyManager = injector.getInstance(ProxyManager)
244
+ const targetPort = getPort()
245
+ const proxyPort = getPort()
246
+
247
+ // Create an echo server that returns the request body
248
+ const targetServer = createHttpServer((req, res) => {
249
+ const chunks: Buffer[] = []
250
+ req.on('data', (chunk) => {
251
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array))
252
+ })
253
+ req.on('end', () => {
254
+ const body = Buffer.concat(chunks).toString('utf8')
255
+ res.writeHead(200, { 'Content-Type': 'application/json' })
256
+ res.end(JSON.stringify({ received: JSON.parse(body), method: req.method }))
257
+ })
258
+ })
259
+
260
+ await new Promise<void>((resolve) => {
261
+ targetServer.listen(targetPort, () => resolve())
262
+ })
263
+
264
+ try {
265
+ await proxyManager.addProxy({
266
+ sourceBaseUrl: '/api',
267
+ targetBaseUrl: `http://localhost:${targetPort}`,
268
+ sourcePort: proxyPort,
269
+ })
270
+
271
+ const testData = { name: 'test', value: 123, nested: { key: 'value' } }
272
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/data`, {
273
+ method: 'POST',
274
+ headers: { 'Content-Type': 'application/json' },
275
+ body: JSON.stringify(testData),
276
+ })
277
+
278
+ expect(result.status).toBe(200)
279
+ const data = (await result.json()) as { received: typeof testData; method: string }
280
+ expect(data.received).toEqual(testData)
281
+ expect(data.method).toBe('POST')
282
+ } finally {
283
+ targetServer.close()
284
+ }
285
+ })
286
+ })
287
+
288
+ it('Should handle response cookies transformation', async () => {
289
+ await usingAsync(new Injector(), async (injector) => {
290
+ const proxyManager = injector.getInstance(ProxyManager)
291
+ const targetPort = getPort()
292
+ const proxyPort = getPort()
293
+
294
+ // Create a server that sets cookies
295
+ const targetServer = createHttpServer((_req, res) => {
296
+ res.writeHead(200, {
297
+ 'Content-Type': 'application/json',
298
+ 'Set-Cookie': ['sessionId=abc123; HttpOnly', 'theme=dark; Path=/'],
299
+ })
300
+ res.end(JSON.stringify({ message: 'Cookies set' }))
301
+ })
302
+
303
+ await new Promise<void>((resolve) => {
304
+ targetServer.listen(targetPort, () => resolve())
305
+ })
306
+
307
+ try {
308
+ await proxyManager.addProxy({
309
+ sourceBaseUrl: '/api',
310
+ targetBaseUrl: `http://localhost:${targetPort}`,
311
+ sourcePort: proxyPort,
312
+ responseCookies: (cookies) => {
313
+ // Transform the cookies
314
+ return cookies.map((cookie) => cookie.replace('sessionId=abc123', 'sessionId=xyz789'))
315
+ },
316
+ })
317
+
318
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
319
+ expect(result.status).toBe(200)
320
+
321
+ const setCookieHeader = result.headers.get('set-cookie')
322
+ expect(setCookieHeader).toContain('sessionId=xyz789')
323
+ expect(setCookieHeader).not.toContain('sessionId=abc123')
324
+ } finally {
325
+ targetServer.close()
326
+ }
327
+ })
328
+ })
329
+
330
+ it('Should not match requests outside source base URL', async () => {
331
+ await usingAsync(new Injector(), async (injector) => {
332
+ const proxyManager = injector.getInstance(ProxyManager)
333
+ const staticServerManager = injector.getInstance(StaticServerManager)
334
+
335
+ const proxyPort = getPort()
336
+ const targetPort = getPort()
337
+
338
+ // Create a test file
339
+ const testFile = join(testDir, 'test.json')
340
+ writeFileSync(testFile, JSON.stringify({ message: 'test' }))
341
+
342
+ // Set up target server
343
+ await staticServerManager.addStaticSite({
344
+ baseUrl: '/',
345
+ path: testDir,
346
+ port: targetPort,
347
+ })
348
+
349
+ // Set up proxy server
350
+ await proxyManager.addProxy({
351
+ sourceBaseUrl: '/old',
352
+ targetBaseUrl: `http://localhost:${targetPort}`,
353
+ pathRewrite: (path) => path.replace('/path', ''),
354
+ sourcePort: proxyPort,
355
+ })
356
+
357
+ try {
358
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/different/path`)
359
+
360
+ // Should not proxy, request should be handled by other handlers or return 404
361
+ expect(result.status).not.toBe(200)
362
+ } catch (error) {
363
+ // Connection error is expected when no handler matches
364
+ expect(error).toBeDefined()
365
+ }
366
+ })
367
+ })
368
+
369
+ it('Should handle exact URL matches', async () => {
370
+ await usingAsync(new Injector(), async (injector) => {
371
+ const proxyManager = injector.getInstance(ProxyManager)
372
+ const staticServerManager = injector.getInstance(StaticServerManager)
373
+
374
+ const proxyPort = getPort()
375
+ const targetPort = getPort()
376
+
377
+ // Create a test file
378
+ const exactFile = join(testDir, 'exact.json')
379
+ writeFileSync(
380
+ exactFile,
381
+ JSON.stringify({
382
+ message: 'Exact match test',
383
+ url: '/exact',
384
+ }),
385
+ )
386
+
387
+ // Set up target server
388
+ await staticServerManager.addStaticSite({
389
+ baseUrl: '/',
390
+ path: testDir,
391
+ port: targetPort,
392
+ })
393
+
394
+ // Set up proxy server
395
+ await proxyManager.addProxy({
396
+ sourceBaseUrl: '/exact',
397
+ targetBaseUrl: `http://localhost:${targetPort}`,
398
+ pathRewrite: (path) => path, // Keep the path as-is
399
+ sourcePort: proxyPort,
400
+ })
401
+
402
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/exact/exact.json`)
403
+
404
+ expect(result.status).toBe(200)
405
+ const responseData = (await result.json()) as { message: string; url: string }
406
+ expect(responseData.message).toBe('Exact match test')
407
+ expect(responseData.url).toBe('/exact')
408
+ })
409
+ })
410
+
411
+ it('Should handle server errors gracefully', async () => {
412
+ await usingAsync(new Injector(), async (injector) => {
413
+ const proxyManager = injector.getInstance(ProxyManager)
414
+ const proxyPort = getPort()
415
+ const unavailablePort = getPort() // Get a valid port number that doesn't have a server
416
+
417
+ // Set up proxy to non-existent target
418
+ await proxyManager.addProxy({
419
+ sourceBaseUrl: '/api',
420
+ targetBaseUrl: `http://localhost:${unavailablePort}`,
421
+ sourcePort: proxyPort,
422
+ })
423
+
424
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
425
+ expect(result.status).toBe(502) // Bad Gateway
426
+ expect(await result.text()).toBe('Bad Gateway')
427
+ })
428
+ })
429
+
430
+ it('Should emit onProxyFailed event on error', async () => {
431
+ await usingAsync(new Injector(), async (injector) => {
432
+ const proxyManager = injector.getInstance(ProxyManager)
433
+ const proxyPort = getPort()
434
+ const unavailablePort = getPort()
435
+
436
+ let failedEvent: { from: string; to: string; error: unknown } | undefined
437
+
438
+ proxyManager.subscribe('onProxyFailed', (event) => {
439
+ failedEvent = event
440
+ })
441
+
442
+ // Set up proxy to non-existent target
443
+ await proxyManager.addProxy({
444
+ sourceBaseUrl: '/api',
445
+ targetBaseUrl: `http://localhost:${unavailablePort}`,
446
+ sourcePort: proxyPort,
447
+ })
448
+
449
+ await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
450
+
451
+ expect(failedEvent).toBeDefined()
452
+ expect(failedEvent?.from).toBe('/api')
453
+ expect(failedEvent?.to).toContain(`http://localhost:${unavailablePort}`)
454
+ })
455
+ })
456
+
457
+ it('Should validate targetBaseUrl on addProxy', async () => {
458
+ await usingAsync(new Injector(), async (injector) => {
459
+ const proxyManager = injector.getInstance(ProxyManager)
460
+
461
+ await expect(
462
+ proxyManager.addProxy({
463
+ sourceBaseUrl: '/api',
464
+ targetBaseUrl: 'not-a-valid-url',
465
+ sourcePort: getPort(),
466
+ }),
467
+ ).rejects.toThrow('Invalid targetBaseUrl')
468
+ })
469
+ })
470
+
471
+ it('Should filter hop-by-hop headers', async () => {
472
+ await usingAsync(new Injector(), async (injector) => {
473
+ const proxyManager = injector.getInstance(ProxyManager)
474
+ const targetPort = getPort()
475
+ const proxyPort = getPort()
476
+
477
+ let receivedHeaders: Record<string, string | string[] | undefined> = {}
478
+
479
+ // Create a server that captures headers
480
+ const targetServer = createHttpServer((req, res) => {
481
+ receivedHeaders = { ...req.headers }
482
+ res.writeHead(200, { 'Content-Type': 'application/json' })
483
+ res.end(JSON.stringify({ message: 'ok' }))
484
+ })
485
+
486
+ await new Promise<void>((resolve) => {
487
+ targetServer.listen(targetPort, () => resolve())
488
+ })
489
+
490
+ try {
491
+ await proxyManager.addProxy({
492
+ sourceBaseUrl: '/api',
493
+ targetBaseUrl: `http://localhost:${targetPort}`,
494
+ sourcePort: proxyPort,
495
+ headers: (original) => {
496
+ // Verify that Connection header was filtered from original headers
497
+ expect(original.connection).toBeUndefined()
498
+ return original
499
+ },
500
+ })
501
+
502
+ await fetch(`http://127.0.0.1:${proxyPort}/api/test`, {
503
+ headers: {
504
+ Connection: 'keep-alive',
505
+ 'X-Custom-Header': 'should-be-forwarded',
506
+ 'X-Hop-Header': 'test-value',
507
+ },
508
+ })
509
+
510
+ // Note: fetch() may add its own Connection header when making the request
511
+ // The important thing is that custom headers are forwarded
512
+ expect(receivedHeaders['x-custom-header']).toBe('should-be-forwarded')
513
+ expect(receivedHeaders['x-hop-header']).toBe('test-value')
514
+ } finally {
515
+ targetServer.close()
516
+ }
517
+ })
518
+ })
519
+
520
+ it('Should preserve multiple Set-Cookie headers from target', async () => {
521
+ await usingAsync(new Injector(), async (injector) => {
522
+ const proxyManager = injector.getInstance(ProxyManager)
523
+ const targetPort = getPort()
524
+ const proxyPort = getPort()
525
+
526
+ const targetServer = createHttpServer((_req, res) => {
527
+ res.writeHead(200, {
528
+ 'Content-Type': 'application/json',
529
+ 'Set-Cookie': ['cookieA=a; Path=/; HttpOnly', 'cookieB=b; Path=/', 'cookieC=c; Path=/'],
530
+ })
531
+ res.end(JSON.stringify({ ok: true }))
532
+ })
533
+
534
+ await new Promise<void>((resolve) => {
535
+ targetServer.listen(targetPort, () => resolve())
536
+ })
537
+
538
+ try {
539
+ await proxyManager.addProxy({
540
+ sourceBaseUrl: '/api',
541
+ targetBaseUrl: `http://localhost:${targetPort}`,
542
+ sourcePort: proxyPort,
543
+ })
544
+
545
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
546
+ expect(result.status).toBe(200)
547
+
548
+ const anyHeaders = result.headers as unknown as { getSetCookie?: () => string[] }
549
+ const cookies = anyHeaders.getSetCookie?.()
550
+ if (cookies) {
551
+ expect(cookies.length).toBeGreaterThanOrEqual(3)
552
+ expect(cookies.join('\n')).toContain('cookieA=a')
553
+ expect(cookies.join('\n')).toContain('cookieB=b')
554
+ expect(cookies.join('\n')).toContain('cookieC=c')
555
+ } else {
556
+ // Fallback: combined header still should contain at least one cookie
557
+ const setCookieHeader = result.headers.get('set-cookie')
558
+ expect(setCookieHeader).toBeTruthy()
559
+ expect(setCookieHeader as string).toContain('cookieA=a')
560
+ }
561
+ } finally {
562
+ targetServer.close()
563
+ }
564
+ })
565
+ })
566
+
567
+ it('Should add X-Forwarded headers to target request', async () => {
568
+ await usingAsync(new Injector(), async (injector) => {
569
+ const proxyManager = injector.getInstance(ProxyManager)
570
+ const targetPort = getPort()
571
+ const proxyPort = getPort()
572
+
573
+ let received: Record<string, string | string[] | undefined> = {}
574
+ const targetServer = createHttpServer((req, res) => {
575
+ received = { ...req.headers }
576
+ res.writeHead(200, { 'Content-Type': 'application/json' })
577
+ res.end(JSON.stringify({ ok: true }))
578
+ })
579
+
580
+ await new Promise<void>((resolve) => {
581
+ targetServer.listen(targetPort, () => resolve())
582
+ })
583
+
584
+ try {
585
+ await proxyManager.addProxy({
586
+ sourceBaseUrl: '/api',
587
+ targetBaseUrl: `http://localhost:${targetPort}`,
588
+ sourcePort: proxyPort,
589
+ })
590
+
591
+ await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
592
+
593
+ expect(received['x-forwarded-for']).toBeTruthy()
594
+ expect(received['x-forwarded-host']).toBeTruthy()
595
+ expect(received['x-forwarded-proto']).toBeTruthy()
596
+ } finally {
597
+ targetServer.close()
598
+ }
599
+ })
600
+ })
601
+
602
+ it('Should abort upstream when client disconnects', async () => {
603
+ await usingAsync(new Injector(), async (injector) => {
604
+ const proxyManager = injector.getInstance(ProxyManager)
605
+ const targetPort = getPort()
606
+ const proxyPort = getPort()
607
+
608
+ let chunksWritten = 0
609
+ let closedEarly = false
610
+ const totalChunks = 100
611
+ const targetServer = createHttpServer((_req, res) => {
612
+ res.writeHead(200, { 'Content-Type': 'text/plain' })
613
+ const interval = setInterval(() => {
614
+ chunksWritten++
615
+ res.write('chunk\n')
616
+ if (chunksWritten >= totalChunks) {
617
+ clearInterval(interval)
618
+ res.end('done')
619
+ }
620
+ }, 5)
621
+ res.on('close', () => {
622
+ closedEarly = chunksWritten < totalChunks
623
+ clearInterval(interval)
624
+ })
625
+ })
626
+
627
+ await new Promise<void>((resolve) => {
628
+ targetServer.listen(targetPort, () => resolve())
629
+ })
630
+
631
+ try {
632
+ await proxyManager.addProxy({
633
+ sourceBaseUrl: '/api',
634
+ targetBaseUrl: `http://localhost:${targetPort}`,
635
+ sourcePort: proxyPort,
636
+ })
637
+
638
+ const controller = new AbortController()
639
+ const fetchPromise = fetch(`http://127.0.0.1:${proxyPort}/api/stream`, { signal: controller.signal }).then(
640
+ async (response) => {
641
+ // Start reading the body stream
642
+ const reader = response.body?.getReader()
643
+ if (!reader) throw new Error('No body')
644
+ // Read one chunk
645
+ await reader.read()
646
+ // Then abort
647
+ controller.abort()
648
+ // Try to read more (should fail)
649
+ return reader.read()
650
+ },
651
+ )
652
+
653
+ await expect(fetchPromise).rejects.toBeDefined()
654
+
655
+ // Give the server a moment to receive the abort
656
+ await new Promise((r) => setTimeout(r, 100))
657
+
658
+ expect(closedEarly).toBe(true)
659
+ } finally {
660
+ targetServer.close()
661
+ }
662
+ })
663
+ })
664
+
665
+ it('Should handle request timeout', async () => {
666
+ await usingAsync(new Injector(), async (injector) => {
667
+ const proxyManager = injector.getInstance(ProxyManager)
668
+ const targetPort = getPort()
669
+ const proxyPort = getPort()
670
+
671
+ let failedEvent: { from: string; to: string; error: unknown } | undefined
672
+
673
+ proxyManager.subscribe('onProxyFailed', (event) => {
674
+ failedEvent = event
675
+ })
676
+
677
+ // Create a server that delays response longer than timeout
678
+ const targetServer = createHttpServer((_req, res) => {
679
+ // Delay for 2 seconds (longer than our 100ms timeout)
680
+ setTimeout(() => {
681
+ res.writeHead(200, { 'Content-Type': 'application/json' })
682
+ res.end(JSON.stringify({ message: 'delayed response' }))
683
+ }, 2000)
684
+ })
685
+
686
+ await new Promise<void>((resolve) => {
687
+ targetServer.listen(targetPort, () => resolve())
688
+ })
689
+
690
+ try {
691
+ await proxyManager.addProxy({
692
+ sourceBaseUrl: '/api',
693
+ targetBaseUrl: `http://localhost:${targetPort}`,
694
+ sourcePort: proxyPort,
695
+ timeout: 100, // Very short timeout to trigger quickly
696
+ })
697
+
698
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
699
+
700
+ // Should return 502 Bad Gateway due to timeout
701
+ expect(result.status).toBe(502)
702
+ expect(await result.text()).toBe('Bad Gateway')
703
+
704
+ // Should emit onProxyFailed event
705
+ expect(failedEvent).toBeDefined()
706
+ expect(failedEvent?.from).toBe('/api')
707
+ expect(failedEvent?.to).toContain(`http://localhost:${targetPort}`)
708
+ } finally {
709
+ targetServer.close()
710
+ }
711
+ })
712
+ })
713
+
714
+ it('Should complete successfully when response is faster than timeout', async () => {
715
+ await usingAsync(new Injector(), async (injector) => {
716
+ const proxyManager = injector.getInstance(ProxyManager)
717
+ const targetPort = getPort()
718
+ const proxyPort = getPort()
719
+
720
+ // Create a fast-responding server
721
+ const targetServer = createHttpServer((_req, res) => {
722
+ res.writeHead(200, { 'Content-Type': 'application/json' })
723
+ res.end(JSON.stringify({ message: 'fast response' }))
724
+ })
725
+
726
+ await new Promise<void>((resolve) => {
727
+ targetServer.listen(targetPort, () => resolve())
728
+ })
729
+
730
+ try {
731
+ await proxyManager.addProxy({
732
+ sourceBaseUrl: '/api',
733
+ targetBaseUrl: `http://localhost:${targetPort}`,
734
+ sourcePort: proxyPort,
735
+ timeout: 5000, // Generous timeout
736
+ })
737
+
738
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
739
+
740
+ // Should succeed
741
+ expect(result.status).toBe(200)
742
+ const data = (await result.json()) as { message: string }
743
+ expect(data.message).toBe('fast response')
744
+ } finally {
745
+ targetServer.close()
746
+ }
747
+ })
748
+ })
749
+
750
+ it('Should use default timeout when not specified', async () => {
751
+ await usingAsync(new Injector(), async (injector) => {
752
+ const proxyManager = injector.getInstance(ProxyManager)
753
+ const targetPort = getPort()
754
+ const proxyPort = getPort()
755
+
756
+ // Create a server with moderate delay (500ms)
757
+ const targetServer = createHttpServer((_req, res) => {
758
+ setTimeout(() => {
759
+ res.writeHead(200, { 'Content-Type': 'application/json' })
760
+ res.end(JSON.stringify({ message: 'response after delay' }))
761
+ }, 500)
762
+ })
763
+
764
+ await new Promise<void>((resolve) => {
765
+ targetServer.listen(targetPort, () => resolve())
766
+ })
767
+
768
+ try {
769
+ // No timeout specified - should use default 30000ms
770
+ await proxyManager.addProxy({
771
+ sourceBaseUrl: '/api',
772
+ targetBaseUrl: `http://localhost:${targetPort}`,
773
+ sourcePort: proxyPort,
774
+ // timeout not specified - uses default
775
+ })
776
+
777
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
778
+
779
+ // Should succeed with default timeout
780
+ expect(result.status).toBe(200)
781
+ const data = (await result.json()) as { message: string }
782
+ expect(data.message).toBe('response after delay')
783
+ } finally {
784
+ targetServer.close()
785
+ }
786
+ })
787
+ })
788
+ })
789
+
790
+ describe('Edge cases and error handling', () => {
791
+ it('Should validate invalid protocol (non-HTTP/HTTPS)', async () => {
792
+ await usingAsync(new Injector(), async (injector) => {
793
+ const proxyManager = injector.getInstance(ProxyManager)
794
+ const proxyPort = getPort()
795
+
796
+ await expect(
797
+ proxyManager.addProxy({
798
+ sourceBaseUrl: '/api',
799
+ targetBaseUrl: 'ftp://example.com',
800
+ sourcePort: proxyPort,
801
+ }),
802
+ ).rejects.toThrow('Invalid targetBaseUrl protocol')
803
+ })
804
+ })
805
+
806
+ it('Should handle invalid target URL created by pathRewrite', async () => {
807
+ await usingAsync(new Injector(), async (injector) => {
808
+ const proxyManager = injector.getInstance(ProxyManager)
809
+ const targetPort = getPort()
810
+ const proxyPort = getPort()
811
+
812
+ let failedEvent: { from: string; to: string; error: unknown } | undefined
813
+
814
+ proxyManager.subscribe('onProxyFailed', (event) => {
815
+ failedEvent = event
816
+ })
817
+
818
+ // Create a simple server
819
+ const targetServer = createHttpServer((_req, res) => {
820
+ res.writeHead(200, { 'Content-Type': 'application/json' })
821
+ res.end(JSON.stringify({ message: 'ok' }))
822
+ })
823
+
824
+ await new Promise<void>((resolve) => {
825
+ targetServer.listen(targetPort, () => resolve())
826
+ })
827
+
828
+ try {
829
+ await proxyManager.addProxy({
830
+ sourceBaseUrl: '/api',
831
+ targetBaseUrl: `http://localhost:${targetPort}`,
832
+ sourcePort: proxyPort,
833
+ // PathHelper.joinUrl now properly handles edge cases, making previously
834
+ // "invalid" URLs valid. The test now verifies that the proxy still works
835
+ // even with unusual pathRewrite results.
836
+ pathRewrite: () => '://unusual-but-valid',
837
+ })
838
+
839
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
840
+
841
+ // With PathHelper.joinUrl, the URL is now valid and routes to the target server
842
+ // The target server returns 200, demonstrating robust URL handling
843
+ expect(result.status).toBe(200)
844
+ const body = await result.json()
845
+ expect(body).toStrictEqual({ message: 'ok' })
846
+
847
+ // No error event should be emitted since the request succeeded
848
+ expect(failedEvent).toBeUndefined()
849
+ } finally {
850
+ targetServer.close()
851
+ }
852
+ })
853
+ })
854
+
855
+ it('Should handle number-type header values', async () => {
856
+ await usingAsync(new Injector(), async (injector) => {
857
+ const proxyManager = injector.getInstance(ProxyManager)
858
+ const targetPort = getPort()
859
+ const proxyPort = getPort()
860
+
861
+ let receivedHeaders: Record<string, string | string[] | undefined> = {}
862
+ let transformedHeaders: OutgoingHttpHeaders | undefined
863
+
864
+ const targetServer = createHttpServer((req, res) => {
865
+ receivedHeaders = { ...req.headers }
866
+ res.writeHead(200, { 'Content-Type': 'application/json' })
867
+ res.end(JSON.stringify({ ok: true }))
868
+ })
869
+
870
+ await new Promise<void>((resolve) => {
871
+ targetServer.listen(targetPort, () => resolve())
872
+ })
873
+
874
+ try {
875
+ await proxyManager.addProxy({
876
+ sourceBaseUrl: '/api',
877
+ targetBaseUrl: `http://localhost:${targetPort}`,
878
+ sourcePort: proxyPort,
879
+ headers: (original) => {
880
+ transformedHeaders = {
881
+ ...original,
882
+ 'X-Custom-Number': 12345 as unknown as string,
883
+ 'X-Custom-String': 'string-value',
884
+ }
885
+ return transformedHeaders
886
+ },
887
+ })
888
+
889
+ await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
890
+
891
+ // Verify headers transformer was called and returns correct types
892
+ expect(transformedHeaders).toBeDefined()
893
+ expect(transformedHeaders?.['X-Custom-Number']).toBe(12345)
894
+ expect(transformedHeaders?.['X-Custom-String']).toBe('string-value')
895
+
896
+ // Verify string header was forwarded
897
+ expect(receivedHeaders['x-custom-string']).toBe('string-value')
898
+ // Number headers get converted to strings by the proxy logic
899
+ expect(receivedHeaders['x-custom-number']).toBe('12345')
900
+ } finally {
901
+ targetServer.close()
902
+ }
903
+ })
904
+ })
905
+
906
+ it('Should handle array-type header values', async () => {
907
+ await usingAsync(new Injector(), async (injector) => {
908
+ const proxyManager = injector.getInstance(ProxyManager)
909
+ const targetPort = getPort()
910
+ const proxyPort = getPort()
911
+
912
+ let receivedHeaders: Record<string, string | string[] | undefined> = {}
913
+
914
+ const targetServer = createHttpServer((req, res) => {
915
+ receivedHeaders = { ...req.headers }
916
+ res.writeHead(200, { 'Content-Type': 'application/json' })
917
+ res.end(JSON.stringify({ ok: true }))
918
+ })
919
+
920
+ await new Promise<void>((resolve) => {
921
+ targetServer.listen(targetPort, () => resolve())
922
+ })
923
+
924
+ try {
925
+ await proxyManager.addProxy({
926
+ sourceBaseUrl: '/api',
927
+ targetBaseUrl: `http://localhost:${targetPort}`,
928
+ sourcePort: proxyPort,
929
+ headers: () => ({
930
+ 'X-Array-Header': ['value1', 'value2', 'value3'] as unknown as string,
931
+ }),
932
+ })
933
+
934
+ await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
935
+
936
+ expect(receivedHeaders['x-array-header']).toBe('value1, value2, value3')
937
+ } finally {
938
+ targetServer.close()
939
+ }
940
+ })
941
+ })
942
+
943
+ it('Should handle undefined header values', async () => {
944
+ await usingAsync(new Injector(), async (injector) => {
945
+ const proxyManager = injector.getInstance(ProxyManager)
946
+ const targetPort = getPort()
947
+ const proxyPort = getPort()
948
+
949
+ let receivedHeaders: Record<string, string | string[] | undefined> = {}
950
+
951
+ const targetServer = createHttpServer((req, res) => {
952
+ receivedHeaders = { ...req.headers }
953
+ res.writeHead(200, { 'Content-Type': 'application/json' })
954
+ res.end(JSON.stringify({ ok: true }))
955
+ })
956
+
957
+ await new Promise<void>((resolve) => {
958
+ targetServer.listen(targetPort, () => resolve())
959
+ })
960
+
961
+ try {
962
+ await proxyManager.addProxy({
963
+ sourceBaseUrl: '/api',
964
+ targetBaseUrl: `http://localhost:${targetPort}`,
965
+ sourcePort: proxyPort,
966
+ headers: () => ({
967
+ 'X-Defined-Header': 'defined',
968
+ 'X-Undefined-Header': undefined as unknown as string,
969
+ }),
970
+ })
971
+
972
+ await fetch(`http://127.0.0.1:${proxyPort}/api/test`)
973
+
974
+ expect(receivedHeaders['x-defined-header']).toBe('defined')
975
+ expect(receivedHeaders['x-undefined-header']).toBeUndefined()
976
+ } finally {
977
+ targetServer.close()
978
+ }
979
+ })
980
+ })
981
+ })
982
+
983
+ describe('WebSocket proxy functionality', () => {
984
+ it('Should proxy WebSocket connections when enabled', async () => {
985
+ await usingAsync(new Injector(), async (injector) => {
986
+ const proxyManager = injector.getInstance(ProxyManager)
987
+ const targetPort = getPort()
988
+ const proxyPort = getPort()
989
+
990
+ // Create target WebSocket server
991
+ const { server: targetServer, wss: targetWss } = await createWsTestServer(targetPort, (ws) => {
992
+ ws.on('message', (data: RawData) => {
993
+ ws.send(`echo: ${rawDataToString(data)}`)
994
+ })
995
+ })
996
+
997
+ try {
998
+ // Set up proxy with WebSocket support
999
+ await proxyManager.addProxy({
1000
+ sourceBaseUrl: '/ws',
1001
+ targetBaseUrl: `http://localhost:${targetPort}`,
1002
+ sourcePort: proxyPort,
1003
+ enableWebsockets: true,
1004
+ })
1005
+
1006
+ // Connect WebSocket client through proxy
1007
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1008
+
1009
+ await new Promise<void>((resolve, reject) => {
1010
+ clientWs.on('open', () => {
1011
+ clientWs.send('hello')
1012
+ })
1013
+
1014
+ clientWs.on('message', (data: RawData) => {
1015
+ expect(rawDataToString(data)).toBe('echo: hello')
1016
+ clientWs.close()
1017
+ resolve()
1018
+ })
1019
+
1020
+ clientWs.on('error', (err) => {
1021
+ clientWs.close()
1022
+ reject(err)
1023
+ })
1024
+ })
1025
+ } finally {
1026
+ targetWss.close()
1027
+ targetServer.close()
1028
+ }
1029
+ })
1030
+ })
1031
+
1032
+ it('Should handle bidirectional WebSocket communication', async () => {
1033
+ await usingAsync(new Injector(), async (injector) => {
1034
+ const proxyManager = injector.getInstance(ProxyManager)
1035
+ const targetPort = getPort()
1036
+ const proxyPort = getPort()
1037
+
1038
+ // Create target WebSocket server
1039
+ const targetServer = createHttpServer()
1040
+ const targetWss = new WebSocketServer({ server: targetServer })
1041
+
1042
+ targetWss.on('connection', (ws) => {
1043
+ // Send messages to client
1044
+ ws.send('server-message-1')
1045
+ ws.send('server-message-2')
1046
+
1047
+ ws.on('message', (data: RawData) => {
1048
+ const message = Buffer.isBuffer(data)
1049
+ ? data.toString('utf8')
1050
+ : Array.isArray(data)
1051
+ ? Buffer.concat(data).toString('utf8')
1052
+ : Buffer.from(data).toString('utf8')
1053
+ ws.send(`received: ${message}`)
1054
+ })
1055
+ })
1056
+
1057
+ await new Promise<void>((resolve) => {
1058
+ targetServer.listen(targetPort, () => resolve())
1059
+ })
1060
+
1061
+ try {
1062
+ await proxyManager.addProxy({
1063
+ sourceBaseUrl: '/ws',
1064
+ targetBaseUrl: `http://localhost:${targetPort}`,
1065
+ sourcePort: proxyPort,
1066
+ enableWebsockets: true,
1067
+ })
1068
+
1069
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1070
+
1071
+ const receivedMessages: string[] = []
1072
+
1073
+ await new Promise<void>((resolve, reject) => {
1074
+ clientWs.on('open', () => {
1075
+ clientWs.send('client-message-1')
1076
+ clientWs.send('client-message-2')
1077
+ })
1078
+
1079
+ clientWs.on('message', (data: RawData) => {
1080
+ const message = Buffer.isBuffer(data)
1081
+ ? data.toString('utf8')
1082
+ : Array.isArray(data)
1083
+ ? Buffer.concat(data).toString('utf8')
1084
+ : Buffer.from(data).toString('utf8')
1085
+ receivedMessages.push(message)
1086
+
1087
+ if (receivedMessages.length >= 4) {
1088
+ expect(receivedMessages).toContain('server-message-1')
1089
+ expect(receivedMessages).toContain('server-message-2')
1090
+ expect(receivedMessages).toContain('received: client-message-1')
1091
+ expect(receivedMessages).toContain('received: client-message-2')
1092
+ clientWs.close()
1093
+ resolve()
1094
+ }
1095
+ })
1096
+
1097
+ clientWs.on('error', (err) => {
1098
+ clientWs.close()
1099
+ reject(err)
1100
+ })
1101
+ })
1102
+ } finally {
1103
+ targetWss.close()
1104
+ targetServer.close()
1105
+ }
1106
+ })
1107
+ })
1108
+
1109
+ it('Should not proxy WebSocket when disabled', async () => {
1110
+ await usingAsync(new Injector(), async (injector) => {
1111
+ const proxyManager = injector.getInstance(ProxyManager)
1112
+ const targetPort = getPort()
1113
+ const proxyPort = getPort()
1114
+
1115
+ // Create target WebSocket server
1116
+ const targetServer = createHttpServer()
1117
+ const targetWss = new WebSocketServer({ server: targetServer })
1118
+
1119
+ await new Promise<void>((resolve) => {
1120
+ targetServer.listen(targetPort, () => resolve())
1121
+ })
1122
+
1123
+ try {
1124
+ // Set up proxy WITHOUT WebSocket support
1125
+ await proxyManager.addProxy({
1126
+ sourceBaseUrl: '/ws',
1127
+ targetBaseUrl: `http://localhost:${targetPort}`,
1128
+ sourcePort: proxyPort,
1129
+ enableWebsockets: false,
1130
+ })
1131
+
1132
+ // Try to connect WebSocket client through proxy
1133
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1134
+
1135
+ await new Promise<void>((resolve) => {
1136
+ clientWs.on('error', (error) => {
1137
+ // Should fail because WebSocket is not enabled
1138
+ expect(error).toBeDefined()
1139
+ clientWs.close()
1140
+ resolve()
1141
+ })
1142
+
1143
+ clientWs.on('open', () => {
1144
+ // Should not open successfully
1145
+ clientWs.close()
1146
+ resolve()
1147
+ })
1148
+ })
1149
+ } finally {
1150
+ targetWss.close()
1151
+ targetServer.close()
1152
+ }
1153
+ })
1154
+ })
1155
+
1156
+ it('Should preserve WebSocket subprotocols', async () => {
1157
+ await usingAsync(new Injector(), async (injector) => {
1158
+ const proxyManager = injector.getInstance(ProxyManager)
1159
+ const targetPort = getPort()
1160
+ const proxyPort = getPort()
1161
+
1162
+ let receivedProtocol: string | undefined
1163
+
1164
+ // Create target WebSocket server
1165
+ const targetServer = createHttpServer()
1166
+ const targetWss = new WebSocketServer({ server: targetServer })
1167
+
1168
+ targetWss.on('connection', (ws, req) => {
1169
+ receivedProtocol = req.headers['sec-websocket-protocol'] as string
1170
+ ws.send('connected')
1171
+ })
1172
+
1173
+ await new Promise<void>((resolve) => {
1174
+ targetServer.listen(targetPort, () => resolve())
1175
+ })
1176
+
1177
+ try {
1178
+ await proxyManager.addProxy({
1179
+ sourceBaseUrl: '/ws',
1180
+ targetBaseUrl: `http://localhost:${targetPort}`,
1181
+ sourcePort: proxyPort,
1182
+ enableWebsockets: true,
1183
+ })
1184
+
1185
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`, ['v1.chat', 'v2.chat'])
1186
+
1187
+ await new Promise<void>((resolve, reject) => {
1188
+ clientWs.on('open', () => {
1189
+ // Wait for server message
1190
+ })
1191
+
1192
+ clientWs.on('message', (data: RawData) => {
1193
+ const message = Buffer.isBuffer(data)
1194
+ ? data.toString('utf8')
1195
+ : Array.isArray(data)
1196
+ ? Buffer.concat(data).toString('utf8')
1197
+ : Buffer.from(data).toString('utf8')
1198
+ expect(message).toBe('connected')
1199
+ expect(receivedProtocol).toContain('v1.chat')
1200
+ clientWs.close()
1201
+ resolve()
1202
+ })
1203
+
1204
+ clientWs.on('error', (err) => {
1205
+ clientWs.close()
1206
+ reject(err)
1207
+ })
1208
+ })
1209
+ } finally {
1210
+ targetWss.close()
1211
+ targetServer.close()
1212
+ }
1213
+ })
1214
+ })
1215
+
1216
+ it('Should handle WebSocket path rewriting', async () => {
1217
+ await usingAsync(new Injector(), async (injector) => {
1218
+ const proxyManager = injector.getInstance(ProxyManager)
1219
+ const targetPort = getPort()
1220
+ const proxyPort = getPort()
1221
+
1222
+ let receivedPath: string | undefined
1223
+
1224
+ // Create target WebSocket server
1225
+ const targetServer = createHttpServer()
1226
+ const targetWss = new WebSocketServer({ server: targetServer })
1227
+
1228
+ targetWss.on('connection', (ws, req) => {
1229
+ receivedPath = req.url
1230
+ ws.send('connected')
1231
+ })
1232
+
1233
+ await new Promise<void>((resolve) => {
1234
+ targetServer.listen(targetPort, () => resolve())
1235
+ })
1236
+
1237
+ try {
1238
+ await proxyManager.addProxy({
1239
+ sourceBaseUrl: '/old/ws',
1240
+ targetBaseUrl: `http://localhost:${targetPort}`,
1241
+ sourcePort: proxyPort,
1242
+ enableWebsockets: true,
1243
+ pathRewrite: (path) => path.replace('/chat', '/api/chat'),
1244
+ })
1245
+
1246
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/old/ws/chat`)
1247
+
1248
+ await new Promise<void>((resolve, reject) => {
1249
+ clientWs.on('message', (data: RawData) => {
1250
+ const message = Buffer.isBuffer(data)
1251
+ ? data.toString('utf8')
1252
+ : Array.isArray(data)
1253
+ ? Buffer.concat(data).toString('utf8')
1254
+ : Buffer.from(data).toString('utf8')
1255
+ expect(message).toBe('connected')
1256
+ expect(receivedPath).toBe('/api/chat')
1257
+ clientWs.close()
1258
+ resolve()
1259
+ })
1260
+
1261
+ clientWs.on('error', (err) => {
1262
+ clientWs.close()
1263
+ reject(err)
1264
+ })
1265
+ })
1266
+ } finally {
1267
+ targetWss.close()
1268
+ targetServer.close()
1269
+ }
1270
+ })
1271
+ })
1272
+
1273
+ it('Should handle WebSocket connection closure from client', async () => {
1274
+ await usingAsync(new Injector(), async (injector) => {
1275
+ const proxyManager = injector.getInstance(ProxyManager)
1276
+ const targetPort = getPort()
1277
+ const proxyPort = getPort()
1278
+
1279
+ let serverClosed = false
1280
+
1281
+ // Create target WebSocket server
1282
+ const targetServer = createHttpServer()
1283
+ const targetWss = new WebSocketServer({ server: targetServer })
1284
+
1285
+ targetWss.on('connection', (ws) => {
1286
+ ws.on('close', () => {
1287
+ serverClosed = true
1288
+ })
1289
+ })
1290
+
1291
+ await new Promise<void>((resolve) => {
1292
+ targetServer.listen(targetPort, () => resolve())
1293
+ })
1294
+
1295
+ try {
1296
+ await proxyManager.addProxy({
1297
+ sourceBaseUrl: '/ws',
1298
+ targetBaseUrl: `http://localhost:${targetPort}`,
1299
+ sourcePort: proxyPort,
1300
+ enableWebsockets: true,
1301
+ })
1302
+
1303
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1304
+
1305
+ await new Promise<void>((resolve, reject) => {
1306
+ clientWs.on('open', () => {
1307
+ clientWs.close()
1308
+ })
1309
+
1310
+ clientWs.on('close', () => {
1311
+ // Give server time to process close event
1312
+ setTimeout(() => {
1313
+ expect(serverClosed).toBe(true)
1314
+ resolve()
1315
+ }, 100)
1316
+ })
1317
+
1318
+ clientWs.on('error', (err) => {
1319
+ clientWs.close()
1320
+ reject(err)
1321
+ })
1322
+ })
1323
+ } finally {
1324
+ targetWss.close()
1325
+ targetServer.close()
1326
+ }
1327
+ })
1328
+ })
1329
+
1330
+ it('Should handle WebSocket connection closure from server', async () => {
1331
+ await usingAsync(new Injector(), async (injector) => {
1332
+ const proxyManager = injector.getInstance(ProxyManager)
1333
+ const targetPort = getPort()
1334
+ const proxyPort = getPort()
1335
+
1336
+ // Create target WebSocket server
1337
+ const targetServer = createHttpServer()
1338
+ const targetWss = new WebSocketServer({ server: targetServer })
1339
+
1340
+ targetWss.on('connection', (ws) => {
1341
+ // Close connection immediately after opening
1342
+ ws.send('goodbye')
1343
+ setTimeout(() => ws.close(), 50)
1344
+ })
1345
+
1346
+ await new Promise<void>((resolve) => {
1347
+ targetServer.listen(targetPort, () => resolve())
1348
+ })
1349
+
1350
+ try {
1351
+ await proxyManager.addProxy({
1352
+ sourceBaseUrl: '/ws',
1353
+ targetBaseUrl: `http://localhost:${targetPort}`,
1354
+ sourcePort: proxyPort,
1355
+ enableWebsockets: true,
1356
+ })
1357
+
1358
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1359
+
1360
+ await new Promise<void>((resolve, reject) => {
1361
+ let messageReceived = false
1362
+
1363
+ clientWs.on('message', (data: RawData) => {
1364
+ const message = Buffer.isBuffer(data)
1365
+ ? data.toString('utf8')
1366
+ : Array.isArray(data)
1367
+ ? Buffer.concat(data).toString('utf8')
1368
+ : Buffer.from(data).toString('utf8')
1369
+ expect(message).toBe('goodbye')
1370
+ messageReceived = true
1371
+ })
1372
+
1373
+ clientWs.on('close', () => {
1374
+ expect(messageReceived).toBe(true)
1375
+ resolve()
1376
+ })
1377
+
1378
+ clientWs.on('error', (err) => {
1379
+ clientWs.close()
1380
+ reject(err)
1381
+ })
1382
+ })
1383
+ } finally {
1384
+ targetWss.close()
1385
+ targetServer.close()
1386
+ }
1387
+ })
1388
+ })
1389
+
1390
+ it('Should emit onWebSocketProxyFailed event on error', async () => {
1391
+ await usingAsync(new Injector(), async (injector) => {
1392
+ const proxyManager = injector.getInstance(ProxyManager)
1393
+ const proxyPort = getPort()
1394
+ const unavailablePort = getPort()
1395
+
1396
+ let failedEvent: { from: string; to: string; error: unknown } | undefined
1397
+
1398
+ proxyManager.subscribe('onWebSocketProxyFailed', (event) => {
1399
+ failedEvent = event
1400
+ })
1401
+
1402
+ await proxyManager.addProxy({
1403
+ sourceBaseUrl: '/ws',
1404
+ targetBaseUrl: `http://localhost:${unavailablePort}`,
1405
+ sourcePort: proxyPort,
1406
+ enableWebsockets: true,
1407
+ })
1408
+
1409
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1410
+
1411
+ await new Promise<void>((resolve) => {
1412
+ clientWs.on('error', () => {
1413
+ // Expected error
1414
+ })
1415
+
1416
+ clientWs.on('close', () => {
1417
+ // Give time for event to be emitted
1418
+ setTimeout(() => {
1419
+ expect(failedEvent).toBeDefined()
1420
+ expect(failedEvent?.from).toBe('/ws')
1421
+ expect(failedEvent?.to).toContain(`http://localhost:${unavailablePort}`)
1422
+ resolve()
1423
+ }, 100)
1424
+ })
1425
+ })
1426
+ })
1427
+ })
1428
+
1429
+ it('Should handle large WebSocket messages', async () => {
1430
+ await usingAsync(new Injector(), async (injector) => {
1431
+ const proxyManager = injector.getInstance(ProxyManager)
1432
+ const targetPort = getPort()
1433
+ const proxyPort = getPort()
1434
+
1435
+ // Create target WebSocket server
1436
+ const targetServer = createHttpServer()
1437
+ const targetWss = new WebSocketServer({ server: targetServer })
1438
+
1439
+ targetWss.on('connection', (ws) => {
1440
+ ws.on('message', (data: RawData) => {
1441
+ // Echo back the large message
1442
+ ws.send(data)
1443
+ })
1444
+ })
1445
+
1446
+ await new Promise<void>((resolve) => {
1447
+ targetServer.listen(targetPort, () => resolve())
1448
+ })
1449
+
1450
+ try {
1451
+ await proxyManager.addProxy({
1452
+ sourceBaseUrl: '/ws',
1453
+ targetBaseUrl: `http://localhost:${targetPort}`,
1454
+ sourcePort: proxyPort,
1455
+ enableWebsockets: true,
1456
+ })
1457
+
1458
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1459
+
1460
+ // Create a large message (1MB)
1461
+ const largeMessage = 'x'.repeat(1024 * 1024)
1462
+
1463
+ await new Promise<void>((resolve, reject) => {
1464
+ clientWs.on('open', () => {
1465
+ clientWs.send(largeMessage)
1466
+ })
1467
+
1468
+ clientWs.on('message', (data: RawData) => {
1469
+ const dataStr = Buffer.isBuffer(data)
1470
+ ? data.toString('utf8')
1471
+ : Array.isArray(data)
1472
+ ? Buffer.concat(data).toString('utf8')
1473
+ : Buffer.from(data).toString('utf8')
1474
+ expect(dataStr.length).toBe(largeMessage.length)
1475
+ expect(dataStr).toBe(largeMessage)
1476
+ clientWs.close()
1477
+ resolve()
1478
+ })
1479
+
1480
+ clientWs.on('error', (err) => {
1481
+ clientWs.close()
1482
+ reject(err)
1483
+ })
1484
+ })
1485
+ } finally {
1486
+ targetWss.close()
1487
+ targetServer.close()
1488
+ }
1489
+ })
1490
+ })
1491
+
1492
+ it('Should handle binary WebSocket messages', async () => {
1493
+ await usingAsync(new Injector(), async (injector) => {
1494
+ const proxyManager = injector.getInstance(ProxyManager)
1495
+ const targetPort = getPort()
1496
+ const proxyPort = getPort()
1497
+
1498
+ // Create target WebSocket server
1499
+ const targetServer = createHttpServer()
1500
+ const targetWss = new WebSocketServer({ server: targetServer })
1501
+
1502
+ targetWss.on('connection', (ws) => {
1503
+ ws.on('message', (data: RawData) => {
1504
+ // Echo back the binary data
1505
+ ws.send(data)
1506
+ })
1507
+ })
1508
+
1509
+ await new Promise<void>((resolve) => {
1510
+ targetServer.listen(targetPort, () => resolve())
1511
+ })
1512
+
1513
+ try {
1514
+ await proxyManager.addProxy({
1515
+ sourceBaseUrl: '/ws',
1516
+ targetBaseUrl: `http://localhost:${targetPort}`,
1517
+ sourcePort: proxyPort,
1518
+ enableWebsockets: true,
1519
+ })
1520
+
1521
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1522
+
1523
+ // Create binary data
1524
+ const binaryData = Buffer.from([0x01, 0x02, 0x03, 0x04, 0x05, 0xff, 0xfe, 0xfd])
1525
+
1526
+ await new Promise<void>((resolve, reject) => {
1527
+ clientWs.on('open', () => {
1528
+ clientWs.send(binaryData)
1529
+ })
1530
+
1531
+ clientWs.on('message', (data) => {
1532
+ expect(Buffer.isBuffer(data)).toBe(true)
1533
+ expect(Buffer.compare(data as Buffer, binaryData)).toBe(0)
1534
+ clientWs.close()
1535
+ resolve()
1536
+ })
1537
+
1538
+ clientWs.on('error', (err) => {
1539
+ clientWs.close()
1540
+ reject(err)
1541
+ })
1542
+ })
1543
+ } finally {
1544
+ targetWss.close()
1545
+ targetServer.close()
1546
+ }
1547
+ })
1548
+ })
1549
+
1550
+ it('Should apply header transformations to WebSocket upgrade', async () => {
1551
+ await usingAsync(new Injector(), async (injector) => {
1552
+ const proxyManager = injector.getInstance(ProxyManager)
1553
+ const targetPort = getPort()
1554
+ const proxyPort = getPort()
1555
+
1556
+ let receivedHeaders: Record<string, string | string[] | undefined> = {}
1557
+
1558
+ // Create target WebSocket server
1559
+ const targetServer = createHttpServer()
1560
+ const targetWss = new WebSocketServer({ server: targetServer })
1561
+
1562
+ targetWss.on('connection', (ws, req) => {
1563
+ receivedHeaders = { ...req.headers }
1564
+ ws.send('connected')
1565
+ })
1566
+
1567
+ await new Promise<void>((resolve) => {
1568
+ targetServer.listen(targetPort, () => resolve())
1569
+ })
1570
+
1571
+ try {
1572
+ await proxyManager.addProxy({
1573
+ sourceBaseUrl: '/ws',
1574
+ targetBaseUrl: `http://localhost:${targetPort}`,
1575
+ sourcePort: proxyPort,
1576
+ enableWebsockets: true,
1577
+ headers: () => ({
1578
+ 'X-Custom-Header': 'custom-value',
1579
+ 'X-Auth-Token': 'bearer-token',
1580
+ }),
1581
+ })
1582
+
1583
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1584
+
1585
+ await new Promise<void>((resolve, reject) => {
1586
+ clientWs.on('message', (data: RawData) => {
1587
+ const message = Buffer.isBuffer(data)
1588
+ ? data.toString('utf8')
1589
+ : Array.isArray(data)
1590
+ ? Buffer.concat(data).toString('utf8')
1591
+ : Buffer.from(data).toString('utf8')
1592
+ expect(message).toBe('connected')
1593
+ expect(receivedHeaders['x-custom-header']).toBe('custom-value')
1594
+ expect(receivedHeaders['x-auth-token']).toBe('bearer-token')
1595
+ clientWs.close()
1596
+ resolve()
1597
+ })
1598
+
1599
+ clientWs.on('error', (err) => {
1600
+ clientWs.close()
1601
+ reject(err)
1602
+ })
1603
+ })
1604
+ } finally {
1605
+ targetWss.close()
1606
+ targetServer.close()
1607
+ }
1608
+ })
1609
+ })
1610
+
1611
+ it('Should handle WebSocket upgrade timeout', async () => {
1612
+ await usingAsync(new Injector(), async (injector) => {
1613
+ const proxyManager = injector.getInstance(ProxyManager)
1614
+ const targetPort = getPort()
1615
+ const proxyPort = getPort()
1616
+
1617
+ // Create a server that delays WebSocket upgrade longer than timeout
1618
+ const targetServer = createHttpServer()
1619
+
1620
+ targetServer.on('upgrade', () => {
1621
+ // Don't respond to upgrade - let it timeout
1622
+ })
1623
+
1624
+ await new Promise<void>((resolve) => {
1625
+ targetServer.listen(targetPort, () => resolve())
1626
+ })
1627
+
1628
+ try {
1629
+ await proxyManager.addProxy({
1630
+ sourceBaseUrl: '/ws',
1631
+ targetBaseUrl: `http://localhost:${targetPort}`,
1632
+ sourcePort: proxyPort,
1633
+ enableWebsockets: true,
1634
+ timeout: 200, // Short timeout
1635
+ })
1636
+
1637
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1638
+
1639
+ await new Promise<void>((resolve, reject) => {
1640
+ const timeoutId = setTimeout(() => {
1641
+ // If we haven't resolved by now, the test failed
1642
+ reject(new Error('Test timeout - WebSocket did not fail as expected'))
1643
+ }, 2000)
1644
+
1645
+ clientWs.on('error', () => {
1646
+ // Expected error
1647
+ })
1648
+
1649
+ clientWs.on('close', () => {
1650
+ // Give time for event to be emitted
1651
+ setTimeout(() => {
1652
+ clearTimeout(timeoutId)
1653
+ // The timeout test might not always trigger the event reliably
1654
+ // Just verify the connection was closed
1655
+ resolve()
1656
+ }, 300)
1657
+ })
1658
+ })
1659
+ } finally {
1660
+ targetServer.close()
1661
+ }
1662
+ })
1663
+ }, 10000)
1664
+
1665
+ it('Should handle unusual path in WebSocket upgrade with PathHelper', async () => {
1666
+ await usingAsync(new Injector(), async (injector) => {
1667
+ const proxyManager = injector.getInstance(ProxyManager)
1668
+ const targetPort = getPort()
1669
+ const proxyPort = getPort()
1670
+
1671
+ let failedEvent: { from: string; to: string; error: unknown } | undefined
1672
+
1673
+ proxyManager.subscribe('onWebSocketProxyFailed', (event) => {
1674
+ failedEvent = event
1675
+ })
1676
+
1677
+ // Create target server that will receive the WebSocket connection
1678
+ const targetServer = createHttpServer()
1679
+ const targetWss = new WebSocketServer({ server: targetServer })
1680
+
1681
+ let connectionReceived = false
1682
+ targetWss.on('connection', () => {
1683
+ connectionReceived = true
1684
+ })
1685
+
1686
+ await new Promise<void>((resolve) => {
1687
+ targetServer.listen(targetPort, () => resolve())
1688
+ })
1689
+
1690
+ try {
1691
+ await proxyManager.addProxy({
1692
+ sourceBaseUrl: '/ws',
1693
+ targetBaseUrl: `http://localhost:${targetPort}`,
1694
+ sourcePort: proxyPort,
1695
+ enableWebsockets: true,
1696
+ // PathHelper.joinUrl now properly handles edge cases, making previously
1697
+ // "invalid" URLs valid. The WebSocket connection succeeds.
1698
+ pathRewrite: () => '://unusual-but-valid',
1699
+ })
1700
+
1701
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws/test`)
1702
+
1703
+ await new Promise<void>((resolve) => {
1704
+ clientWs.on('open', () => {
1705
+ // Connection successful thanks to robust URL handling
1706
+ clientWs.close()
1707
+ })
1708
+
1709
+ clientWs.on('close', () => {
1710
+ // Give time for event to be processed
1711
+ setTimeout(() => {
1712
+ // No error event should be emitted since the connection succeeded
1713
+ expect(failedEvent).toBeUndefined()
1714
+ expect(connectionReceived).toBe(true)
1715
+ resolve()
1716
+ }, 100)
1717
+ })
1718
+ })
1719
+ } finally {
1720
+ targetWss.close()
1721
+ targetServer.close()
1722
+ }
1723
+ })
1724
+ })
1725
+
1726
+ it('Should handle WebSocket header value types (number, array, undefined)', async () => {
1727
+ await usingAsync(new Injector(), async (injector) => {
1728
+ const proxyManager = injector.getInstance(ProxyManager)
1729
+ const targetPort = getPort()
1730
+ const proxyPort = getPort()
1731
+
1732
+ let receivedHeaders: Record<string, string | string[] | undefined> = {}
1733
+
1734
+ // Create target WebSocket server
1735
+ const { server: targetServer, wss: targetWss } = await createWsTestServer(targetPort, (ws, req) => {
1736
+ receivedHeaders = { ...req.headers }
1737
+ ws.send('connected')
1738
+ })
1739
+
1740
+ try {
1741
+ await proxyManager.addProxy({
1742
+ sourceBaseUrl: '/ws',
1743
+ targetBaseUrl: `http://localhost:${targetPort}`,
1744
+ sourcePort: proxyPort,
1745
+ enableWebsockets: true,
1746
+ headers: () => ({
1747
+ 'X-Number-Header': 42 as unknown as string,
1748
+ 'X-Array-Header': ['val1', 'val2'] as unknown as string,
1749
+ 'X-Undefined-Header': undefined as unknown as string,
1750
+ 'X-String-Header': 'string-value',
1751
+ }),
1752
+ })
1753
+
1754
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1755
+
1756
+ await new Promise<void>((resolve, reject) => {
1757
+ clientWs.on('message', (data: RawData) => {
1758
+ expect(rawDataToString(data)).toBe('connected')
1759
+ expect(receivedHeaders['x-number-header']).toBe('42')
1760
+ expect(receivedHeaders['x-array-header']).toBe('val1, val2')
1761
+ expect(receivedHeaders['x-string-header']).toBe('string-value')
1762
+ expect(receivedHeaders['x-undefined-header']).toBeUndefined()
1763
+ clientWs.close()
1764
+ resolve()
1765
+ })
1766
+
1767
+ clientWs.on('error', (err) => {
1768
+ clientWs.close()
1769
+ reject(err)
1770
+ })
1771
+ })
1772
+ } finally {
1773
+ targetWss.close()
1774
+ targetServer.close()
1775
+ }
1776
+ })
1777
+ })
1778
+
1779
+ it('Should handle WebSocket connections with query parameters', async () => {
1780
+ await usingAsync(new Injector(), async (injector) => {
1781
+ const proxyManager = injector.getInstance(ProxyManager)
1782
+ const targetPort = getPort()
1783
+ const proxyPort = getPort()
1784
+
1785
+ let receivedUrl: string | undefined
1786
+
1787
+ const { server: targetServer, wss: targetWss } = await createWsTestServer(targetPort, (ws, req) => {
1788
+ receivedUrl = req.url
1789
+ ws.send('connected')
1790
+ })
1791
+
1792
+ try {
1793
+ await proxyManager.addProxy({
1794
+ sourceBaseUrl: '/ws',
1795
+ targetBaseUrl: `http://localhost:${targetPort}`,
1796
+ sourcePort: proxyPort,
1797
+ enableWebsockets: true,
1798
+ })
1799
+
1800
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws/channel?token=abc123&user=test`)
1801
+
1802
+ await new Promise<void>((resolve, reject) => {
1803
+ clientWs.on('message', (data: RawData) => {
1804
+ expect(rawDataToString(data)).toBe('connected')
1805
+ expect(receivedUrl).toBe('/channel?token=abc123&user=test')
1806
+ clientWs.close()
1807
+ resolve()
1808
+ })
1809
+
1810
+ clientWs.on('error', (err) => {
1811
+ clientWs.close()
1812
+ reject(err)
1813
+ })
1814
+ })
1815
+ } finally {
1816
+ targetWss.close()
1817
+ targetServer.close()
1818
+ }
1819
+ })
1820
+ })
1821
+
1822
+ it('Should handle WebSocket close with code and reason', async () => {
1823
+ await usingAsync(new Injector(), async (injector) => {
1824
+ const proxyManager = injector.getInstance(ProxyManager)
1825
+ const targetPort = getPort()
1826
+ const proxyPort = getPort()
1827
+
1828
+ const { server: targetServer, wss: targetWss } = await createWsTestServer(targetPort, (ws) => {
1829
+ ws.close(1008, 'Policy Violation')
1830
+ })
1831
+
1832
+ try {
1833
+ await proxyManager.addProxy({
1834
+ sourceBaseUrl: '/ws',
1835
+ targetBaseUrl: `http://localhost:${targetPort}`,
1836
+ sourcePort: proxyPort,
1837
+ enableWebsockets: true,
1838
+ })
1839
+
1840
+ const clientWs = new WebSocket(`ws://127.0.0.1:${proxyPort}/ws`)
1841
+
1842
+ await new Promise<void>((resolve) => {
1843
+ clientWs.on('close', (code, reason) => {
1844
+ expect(code).toBe(1008)
1845
+ expect(reason.toString()).toBe('Policy Violation')
1846
+ resolve()
1847
+ })
1848
+ })
1849
+ } finally {
1850
+ targetWss.close()
1851
+ targetServer.close()
1852
+ }
1853
+ })
1854
+ })
1855
+ })
1856
+
1857
+ describe('HTTP Methods and Status Codes', () => {
1858
+ it('Should handle PUT requests with body', async () => {
1859
+ await usingAsync(new Injector(), async (injector) => {
1860
+ const proxyManager = injector.getInstance(ProxyManager)
1861
+ const targetPort = getPort()
1862
+ const proxyPort = getPort()
1863
+
1864
+ const targetServer = await createEchoServer(targetPort, (req, res) => {
1865
+ const chunks: Buffer[] = []
1866
+ req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
1867
+ req.on('end', () => {
1868
+ const body = Buffer.concat(chunks).toString('utf8')
1869
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1870
+ res.end(JSON.stringify({ method: req.method, body: JSON.parse(body) }))
1871
+ })
1872
+ })
1873
+
1874
+ try {
1875
+ await proxyManager.addProxy({
1876
+ sourceBaseUrl: '/api',
1877
+ targetBaseUrl: `http://localhost:${targetPort}`,
1878
+ sourcePort: proxyPort,
1879
+ })
1880
+
1881
+ const testData = { id: 1, name: 'updated' }
1882
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/resource/1`, {
1883
+ method: 'PUT',
1884
+ headers: { 'Content-Type': 'application/json' },
1885
+ body: JSON.stringify(testData),
1886
+ })
1887
+
1888
+ expect(result.status).toBe(200)
1889
+ const data = (await result.json()) as { method: string; body: typeof testData }
1890
+ expect(data.method).toBe('PUT')
1891
+ expect(data.body).toEqual(testData)
1892
+ } finally {
1893
+ targetServer.close()
1894
+ }
1895
+ })
1896
+ })
1897
+
1898
+ it('Should handle PATCH requests with body', async () => {
1899
+ await usingAsync(new Injector(), async (injector) => {
1900
+ const proxyManager = injector.getInstance(ProxyManager)
1901
+ const targetPort = getPort()
1902
+ const proxyPort = getPort()
1903
+
1904
+ const targetServer = await createEchoServer(targetPort, (req, res) => {
1905
+ const chunks: Buffer[] = []
1906
+ req.on('data', (chunk: Buffer) => chunks.push(Buffer.from(chunk)))
1907
+ req.on('end', () => {
1908
+ const body = Buffer.concat(chunks).toString('utf8')
1909
+ res.writeHead(200, { 'Content-Type': 'application/json' })
1910
+ res.end(JSON.stringify({ method: req.method, body: JSON.parse(body) }))
1911
+ })
1912
+ })
1913
+
1914
+ try {
1915
+ await proxyManager.addProxy({
1916
+ sourceBaseUrl: '/api',
1917
+ targetBaseUrl: `http://localhost:${targetPort}`,
1918
+ sourcePort: proxyPort,
1919
+ })
1920
+
1921
+ const testData = { name: 'patched' }
1922
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/resource/1`, {
1923
+ method: 'PATCH',
1924
+ headers: { 'Content-Type': 'application/json' },
1925
+ body: JSON.stringify(testData),
1926
+ })
1927
+
1928
+ expect(result.status).toBe(200)
1929
+ const data = (await result.json()) as { method: string; body: typeof testData }
1930
+ expect(data.method).toBe('PATCH')
1931
+ expect(data.body).toEqual(testData)
1932
+ } finally {
1933
+ targetServer.close()
1934
+ }
1935
+ })
1936
+ })
1937
+
1938
+ it('Should handle DELETE requests', async () => {
1939
+ await usingAsync(new Injector(), async (injector) => {
1940
+ const proxyManager = injector.getInstance(ProxyManager)
1941
+ const targetPort = getPort()
1942
+ const proxyPort = getPort()
1943
+
1944
+ const targetServer = await createEchoServer(targetPort, (_req, res) => {
1945
+ res.writeHead(204, { 'Content-Type': 'application/json' })
1946
+ res.end()
1947
+ })
1948
+
1949
+ try {
1950
+ await proxyManager.addProxy({
1951
+ sourceBaseUrl: '/api',
1952
+ targetBaseUrl: `http://localhost:${targetPort}`,
1953
+ sourcePort: proxyPort,
1954
+ })
1955
+
1956
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/resource/1`, {
1957
+ method: 'DELETE',
1958
+ })
1959
+
1960
+ expect(result.status).toBe(204)
1961
+ } finally {
1962
+ targetServer.close()
1963
+ }
1964
+ })
1965
+ })
1966
+
1967
+ it('Should handle OPTIONS requests (CORS preflight)', async () => {
1968
+ await usingAsync(new Injector(), async (injector) => {
1969
+ const proxyManager = injector.getInstance(ProxyManager)
1970
+ const targetPort = getPort()
1971
+ const proxyPort = getPort()
1972
+
1973
+ const targetServer = await createEchoServer(targetPort, (_req, res) => {
1974
+ res.writeHead(200, {
1975
+ 'Access-Control-Allow-Origin': '*',
1976
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
1977
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1978
+ })
1979
+ res.end()
1980
+ })
1981
+
1982
+ try {
1983
+ await proxyManager.addProxy({
1984
+ sourceBaseUrl: '/api',
1985
+ targetBaseUrl: `http://localhost:${targetPort}`,
1986
+ sourcePort: proxyPort,
1987
+ })
1988
+
1989
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/resource`, {
1990
+ method: 'OPTIONS',
1991
+ })
1992
+
1993
+ expect(result.status).toBe(200)
1994
+ expect(result.headers.get('access-control-allow-origin')).toBe('*')
1995
+ expect(result.headers.get('access-control-allow-methods')).toContain('DELETE')
1996
+ } finally {
1997
+ targetServer.close()
1998
+ }
1999
+ })
2000
+ })
2001
+
2002
+ it('Should handle HEAD requests', async () => {
2003
+ await usingAsync(new Injector(), async (injector) => {
2004
+ const proxyManager = injector.getInstance(ProxyManager)
2005
+ const targetPort = getPort()
2006
+ const proxyPort = getPort()
2007
+
2008
+ const targetServer = await createEchoServer(targetPort, (_req, res) => {
2009
+ res.writeHead(200, {
2010
+ 'Content-Type': 'application/json',
2011
+ 'Content-Length': '100',
2012
+ })
2013
+ res.end()
2014
+ })
2015
+
2016
+ try {
2017
+ await proxyManager.addProxy({
2018
+ sourceBaseUrl: '/api',
2019
+ targetBaseUrl: `http://localhost:${targetPort}`,
2020
+ sourcePort: proxyPort,
2021
+ })
2022
+
2023
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/resource`, {
2024
+ method: 'HEAD',
2025
+ })
2026
+
2027
+ expect(result.status).toBe(200)
2028
+ expect(result.headers.get('content-type')).toBe('application/json')
2029
+ expect(result.headers.get('content-length')).toBe('100')
2030
+ } finally {
2031
+ targetServer.close()
2032
+ }
2033
+ })
2034
+ })
2035
+
2036
+ it('Should handle 4xx client error responses', async () => {
2037
+ await usingAsync(new Injector(), async (injector) => {
2038
+ const proxyManager = injector.getInstance(ProxyManager)
2039
+ const targetPort = getPort()
2040
+ const proxyPort = getPort()
2041
+
2042
+ const targetServer = await createEchoServer(targetPort, (_req, res) => {
2043
+ res.writeHead(404, { 'Content-Type': 'application/json' })
2044
+ res.end(JSON.stringify({ error: 'Not Found' }))
2045
+ })
2046
+
2047
+ try {
2048
+ await proxyManager.addProxy({
2049
+ sourceBaseUrl: '/api',
2050
+ targetBaseUrl: `http://localhost:${targetPort}`,
2051
+ sourcePort: proxyPort,
2052
+ })
2053
+
2054
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/nonexistent`)
2055
+
2056
+ expect(result.status).toBe(404)
2057
+ const data = (await result.json()) as { error: string }
2058
+ expect(data.error).toBe('Not Found')
2059
+ } finally {
2060
+ targetServer.close()
2061
+ }
2062
+ })
2063
+ })
2064
+
2065
+ it('Should handle 5xx server error responses', async () => {
2066
+ await usingAsync(new Injector(), async (injector) => {
2067
+ const proxyManager = injector.getInstance(ProxyManager)
2068
+ const targetPort = getPort()
2069
+ const proxyPort = getPort()
2070
+
2071
+ const targetServer = await createEchoServer(targetPort, (_req, res) => {
2072
+ res.writeHead(500, { 'Content-Type': 'application/json' })
2073
+ res.end(JSON.stringify({ error: 'Internal Server Error' }))
2074
+ })
2075
+
2076
+ try {
2077
+ await proxyManager.addProxy({
2078
+ sourceBaseUrl: '/api',
2079
+ targetBaseUrl: `http://localhost:${targetPort}`,
2080
+ sourcePort: proxyPort,
2081
+ })
2082
+
2083
+ const result = await fetch(`http://127.0.0.1:${proxyPort}/api/error`)
2084
+
2085
+ expect(result.status).toBe(500)
2086
+ const data = (await result.json()) as { error: string }
2087
+ expect(data.error).toBe('Internal Server Error')
2088
+ } finally {
2089
+ targetServer.close()
2090
+ }
2091
+ })
2092
+ })
2093
+ })
2094
+ })