@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,514 @@
1
+ import type { IncomingMessage } from 'http'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { HeaderProcessor } from './header-processor.js'
4
+
5
+ describe('HeaderProcessor', () => {
6
+ const processor = new HeaderProcessor()
7
+
8
+ describe('filterHeaders', () => {
9
+ it('should filter out hop-by-hop headers', () => {
10
+ const headers = {
11
+ 'content-type': 'application/json',
12
+ connection: 'keep-alive',
13
+ 'keep-alive': 'timeout=5',
14
+ 'x-custom-header': 'custom-value',
15
+ 'transfer-encoding': 'chunked',
16
+ }
17
+
18
+ const filtered = processor.filterHeaders(headers)
19
+
20
+ expect(filtered['content-type']).toBe('application/json')
21
+ expect(filtered['x-custom-header']).toBe('custom-value')
22
+ expect(filtered.connection).toBeUndefined()
23
+ expect(filtered['keep-alive']).toBeUndefined()
24
+ expect(filtered['transfer-encoding']).toBeUndefined()
25
+ })
26
+
27
+ it('should preserve all non-hop-by-hop headers', () => {
28
+ const headers = {
29
+ 'content-type': 'text/html',
30
+ 'content-length': '1234',
31
+ authorization: 'Bearer token',
32
+ 'x-custom-header': 'value',
33
+ }
34
+
35
+ const filtered = processor.filterHeaders(headers)
36
+
37
+ expect(filtered).toEqual(headers)
38
+ })
39
+
40
+ it('should handle empty headers object', () => {
41
+ const filtered = processor.filterHeaders({})
42
+ expect(filtered).toEqual({})
43
+ })
44
+ })
45
+
46
+ describe('processCookies', () => {
47
+ it('should parse cookies from request header', () => {
48
+ const req = {
49
+ headers: {
50
+ cookie: 'sessionId=abc123; theme=dark; userId=456',
51
+ },
52
+ } as IncomingMessage
53
+
54
+ const cookies = processor.processCookies(req)
55
+
56
+ expect(cookies).toEqual(['sessionId=abc123', 'theme=dark', 'userId=456'])
57
+ })
58
+
59
+ it('should return empty array when no cookies present', () => {
60
+ const req = {
61
+ headers: {},
62
+ } as IncomingMessage
63
+
64
+ const cookies = processor.processCookies(req)
65
+
66
+ expect(cookies).toEqual([])
67
+ })
68
+
69
+ it('should apply cookie transformer when provided', () => {
70
+ const req = {
71
+ headers: {
72
+ cookie: 'oldCookie=value',
73
+ },
74
+ } as IncomingMessage
75
+
76
+ const transformer = (cookies: string[]) => [...cookies, 'newCookie=newValue']
77
+ const cookies = processor.processCookies(req, transformer)
78
+
79
+ expect(cookies).toEqual(['oldCookie=value', 'newCookie=newValue'])
80
+ })
81
+
82
+ it('should trim cookie values', () => {
83
+ const req = {
84
+ headers: {
85
+ cookie: ' cookie1=value1 ; cookie2=value2 ',
86
+ },
87
+ } as IncomingMessage
88
+
89
+ const cookies = processor.processCookies(req)
90
+
91
+ expect(cookies).toEqual(['cookie1=value1', 'cookie2=value2'])
92
+ })
93
+ })
94
+
95
+ describe('buildForwardedHeaders', () => {
96
+ it('should build X-Forwarded-* headers', () => {
97
+ const req = {
98
+ headers: {
99
+ 'user-agent': 'test-agent',
100
+ host: 'source.com',
101
+ },
102
+ socket: {
103
+ remoteAddress: '192.168.1.100',
104
+ },
105
+ } as unknown as IncomingMessage
106
+
107
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
108
+
109
+ expect(headers.Host).toBe('target.com')
110
+ expect(headers['User-Agent']).toBe('test-agent')
111
+ expect(headers['X-Forwarded-For']).toBe('192.168.1.100')
112
+ expect(headers['X-Forwarded-Host']).toBe('source.com')
113
+ expect(headers['X-Forwarded-Proto']).toBe('http')
114
+ })
115
+
116
+ it('should chain X-Forwarded-For headers', () => {
117
+ const req = {
118
+ headers: {
119
+ 'x-forwarded-for': '10.0.0.1, 10.0.0.2',
120
+ host: 'source.com',
121
+ },
122
+ socket: {
123
+ remoteAddress: '192.168.1.100',
124
+ },
125
+ } as unknown as IncomingMessage
126
+
127
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
128
+
129
+ expect(headers['X-Forwarded-For']).toBe('10.0.0.1, 10.0.0.2, 192.168.1.100')
130
+ })
131
+
132
+ it('should detect HTTPS from encrypted socket', () => {
133
+ const req = {
134
+ headers: {
135
+ host: 'source.com',
136
+ },
137
+ socket: {
138
+ remoteAddress: '192.168.1.100',
139
+ encrypted: true,
140
+ },
141
+ } as unknown as IncomingMessage
142
+
143
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
144
+
145
+ expect(headers['X-Forwarded-Proto']).toBe('https')
146
+ })
147
+
148
+ it('should use existing X-Forwarded-Proto if present', () => {
149
+ const req = {
150
+ headers: {
151
+ 'x-forwarded-proto': 'https',
152
+ host: 'source.com',
153
+ },
154
+ socket: {
155
+ remoteAddress: '192.168.1.100',
156
+ },
157
+ } as unknown as IncomingMessage
158
+
159
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
160
+
161
+ expect(headers['X-Forwarded-Proto']).toBe('https')
162
+ })
163
+
164
+ it('should use default User-Agent when not provided', () => {
165
+ const req = {
166
+ headers: {
167
+ host: 'source.com',
168
+ },
169
+ socket: {
170
+ remoteAddress: '192.168.1.100',
171
+ },
172
+ } as unknown as IncomingMessage
173
+
174
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
175
+
176
+ expect(headers['User-Agent']).toBe('FuryStack-Proxy/1.0')
177
+ })
178
+ })
179
+
180
+ describe('convertHeadersToRecord', () => {
181
+ it('should convert string headers', () => {
182
+ const headers = {
183
+ 'content-type': 'application/json',
184
+ authorization: 'Bearer token',
185
+ }
186
+
187
+ const record = processor.convertHeadersToRecord(headers)
188
+
189
+ expect(record).toEqual(headers)
190
+ })
191
+
192
+ it('should convert number headers to strings', () => {
193
+ const headers = {
194
+ 'content-length': 1234,
195
+ }
196
+
197
+ const record = processor.convertHeadersToRecord(headers)
198
+
199
+ expect(record['content-length']).toBe('1234')
200
+ })
201
+
202
+ it('should join array headers with commas', () => {
203
+ const headers = {
204
+ 'x-custom-header': ['value1', 'value2', 'value3'],
205
+ }
206
+
207
+ const record = processor.convertHeadersToRecord(headers)
208
+
209
+ expect(record['x-custom-header']).toBe('value1, value2, value3')
210
+ })
211
+
212
+ it('should skip undefined headers', () => {
213
+ const headers = {
214
+ 'defined-header': 'value',
215
+ 'undefined-header': undefined,
216
+ }
217
+
218
+ const record = processor.convertHeadersToRecord(headers)
219
+
220
+ expect(record['defined-header']).toBe('value')
221
+ expect(record['undefined-header']).toBeUndefined()
222
+ })
223
+
224
+ it('should exclude cookie header', () => {
225
+ const headers = {
226
+ 'content-type': 'application/json',
227
+ cookie: 'sessionId=abc123',
228
+ }
229
+
230
+ const record = processor.convertHeadersToRecord(headers)
231
+
232
+ expect(record['content-type']).toBe('application/json')
233
+ expect(record.cookie).toBeUndefined()
234
+ })
235
+
236
+ it('should exclude hop-by-hop headers', () => {
237
+ const headers = {
238
+ 'content-type': 'application/json',
239
+ connection: 'keep-alive',
240
+ 'transfer-encoding': 'chunked',
241
+ }
242
+
243
+ const record = processor.convertHeadersToRecord(headers)
244
+
245
+ expect(record['content-type']).toBe('application/json')
246
+ expect(record.connection).toBeUndefined()
247
+ expect(record['transfer-encoding']).toBeUndefined()
248
+ })
249
+ })
250
+
251
+ describe('processRequestHeaders', () => {
252
+ it('should process all headers and return proxy-ready headers', () => {
253
+ const req = {
254
+ headers: {
255
+ 'content-type': 'application/json',
256
+ 'user-agent': 'test-agent',
257
+ cookie: 'sessionId=abc123',
258
+ connection: 'keep-alive',
259
+ },
260
+ socket: {
261
+ remoteAddress: '192.168.1.100',
262
+ },
263
+ } as unknown as IncomingMessage
264
+
265
+ const result = processor.processRequestHeaders(req, 'target.com')
266
+
267
+ expect(result.proxyHeaders['content-type']).toBe('application/json')
268
+ expect(result.proxyHeaders.Host).toBe('target.com')
269
+ expect(result.proxyHeaders['X-Forwarded-For']).toBe('192.168.1.100')
270
+ expect(result.proxyHeaders.Cookie).toBe('sessionId=abc123')
271
+ expect(result.proxyHeaders.connection).toBeUndefined()
272
+ expect(result.finalCookies).toEqual(['sessionId=abc123'])
273
+ })
274
+
275
+ it('should apply header transformer', () => {
276
+ const req = {
277
+ headers: {
278
+ 'content-type': 'application/json',
279
+ },
280
+ socket: {
281
+ remoteAddress: '192.168.1.100',
282
+ },
283
+ } as unknown as IncomingMessage
284
+
285
+ const headerTransformer = () => ({
286
+ 'x-custom-header': 'custom-value',
287
+ })
288
+
289
+ const result = processor.processRequestHeaders(req, 'target.com', {
290
+ headers: headerTransformer,
291
+ })
292
+
293
+ expect(result.proxyHeaders['x-custom-header']).toBe('custom-value')
294
+ expect(result.proxyHeaders['content-type']).toBeUndefined()
295
+ })
296
+
297
+ it('should apply cookie transformer', () => {
298
+ const req = {
299
+ headers: {
300
+ cookie: 'oldCookie=value',
301
+ },
302
+ socket: {
303
+ remoteAddress: '192.168.1.100',
304
+ },
305
+ } as unknown as IncomingMessage
306
+
307
+ const cookieTransformer = (cookies: string[]) => [...cookies, 'newCookie=newValue']
308
+
309
+ const result = processor.processRequestHeaders(req, 'target.com', {
310
+ cookies: cookieTransformer,
311
+ })
312
+
313
+ expect(result.proxyHeaders.Cookie).toBe('oldCookie=value; newCookie=newValue')
314
+ expect(result.finalCookies).toEqual(['oldCookie=value', 'newCookie=newValue'])
315
+ })
316
+
317
+ it('should not include Cookie header when no cookies present', () => {
318
+ const req = {
319
+ headers: {},
320
+ socket: {
321
+ remoteAddress: '192.168.1.100',
322
+ },
323
+ } as unknown as IncomingMessage
324
+
325
+ const result = processor.processRequestHeaders(req, 'target.com')
326
+
327
+ expect(result.proxyHeaders.Cookie).toBeUndefined()
328
+ expect(result.finalCookies).toEqual([])
329
+ })
330
+ })
331
+
332
+ describe('isHopByHopHeader', () => {
333
+ it('should identify hop-by-hop headers', () => {
334
+ expect(processor.isHopByHopHeader('connection')).toBe(true)
335
+ expect(processor.isHopByHopHeader('Connection')).toBe(true)
336
+ expect(processor.isHopByHopHeader('keep-alive')).toBe(true)
337
+ expect(processor.isHopByHopHeader('transfer-encoding')).toBe(true)
338
+ expect(processor.isHopByHopHeader('upgrade')).toBe(true)
339
+ })
340
+
341
+ it('should not identify regular headers as hop-by-hop', () => {
342
+ expect(processor.isHopByHopHeader('content-type')).toBe(false)
343
+ expect(processor.isHopByHopHeader('authorization')).toBe(false)
344
+ expect(processor.isHopByHopHeader('x-custom-header')).toBe(false)
345
+ })
346
+
347
+ it('should identify all standard hop-by-hop headers', () => {
348
+ expect(processor.isHopByHopHeader('proxy-authenticate')).toBe(true)
349
+ expect(processor.isHopByHopHeader('proxy-authorization')).toBe(true)
350
+ expect(processor.isHopByHopHeader('te')).toBe(true)
351
+ expect(processor.isHopByHopHeader('trailer')).toBe(true)
352
+ })
353
+ })
354
+
355
+ describe('Edge cases and complex scenarios', () => {
356
+ it('should handle empty cookie strings correctly', () => {
357
+ const req = {
358
+ headers: {
359
+ cookie: '',
360
+ },
361
+ } as IncomingMessage
362
+
363
+ const cookies = processor.processCookies(req)
364
+ expect(cookies).toEqual([''])
365
+ })
366
+
367
+ it('should handle multiple semicolons in cookie string', () => {
368
+ const req = {
369
+ headers: {
370
+ cookie: 'cookie1=value1;;cookie2=value2',
371
+ },
372
+ } as IncomingMessage
373
+
374
+ const cookies = processor.processCookies(req)
375
+ expect(cookies).toEqual(['cookie1=value1', '', 'cookie2=value2'])
376
+ })
377
+
378
+ it('should handle cookies with special characters', () => {
379
+ const req = {
380
+ headers: {
381
+ cookie: 'session=abc%3D123; token=Bearer%20xyz',
382
+ },
383
+ } as IncomingMessage
384
+
385
+ const cookies = processor.processCookies(req)
386
+ expect(cookies).toEqual(['session=abc%3D123', 'token=Bearer%20xyz'])
387
+ })
388
+
389
+ it('should handle missing remote address gracefully', () => {
390
+ const req = {
391
+ headers: {
392
+ host: 'source.com',
393
+ },
394
+ socket: {},
395
+ } as unknown as IncomingMessage
396
+
397
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
398
+ expect(headers['X-Forwarded-For']).toBe('')
399
+ })
400
+
401
+ it('should handle very long X-Forwarded-For chains', () => {
402
+ const longChain = Array.from({ length: 20 }, (_, i) => `10.0.0.${i + 1}`).join(', ')
403
+ const req = {
404
+ headers: {
405
+ 'x-forwarded-for': longChain,
406
+ host: 'source.com',
407
+ },
408
+ socket: {
409
+ remoteAddress: '192.168.1.100',
410
+ },
411
+ } as unknown as IncomingMessage
412
+
413
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
414
+ expect(headers['X-Forwarded-For']).toContain(longChain)
415
+ expect(headers['X-Forwarded-For']).toContain('192.168.1.100')
416
+ })
417
+
418
+ it('should handle mixed case header names', () => {
419
+ const headers = {
420
+ 'Content-Type': 'application/json',
421
+ 'X-Custom-Header': 'value',
422
+ CONNECTION: 'keep-alive',
423
+ }
424
+
425
+ const filtered = processor.filterHeaders(headers)
426
+ expect(filtered['Content-Type']).toBe('application/json')
427
+ expect(filtered['X-Custom-Header']).toBe('value')
428
+ expect(filtered.CONNECTION).toBeUndefined()
429
+ })
430
+
431
+ it('should handle empty array headers', () => {
432
+ const headers = {
433
+ 'X-Empty-Array': [],
434
+ }
435
+
436
+ const record = processor.convertHeadersToRecord(headers)
437
+ expect(record['X-Empty-Array']).toBe('')
438
+ })
439
+
440
+ it('should preserve header order when processing', () => {
441
+ const req = {
442
+ headers: {
443
+ 'first-header': 'first',
444
+ 'second-header': 'second',
445
+ 'third-header': 'third',
446
+ },
447
+ socket: {
448
+ remoteAddress: '192.168.1.100',
449
+ },
450
+ } as unknown as IncomingMessage
451
+
452
+ const result = processor.processRequestHeaders(req, 'target.com')
453
+ expect(result.proxyHeaders['first-header']).toBe('first')
454
+ expect(result.proxyHeaders['second-header']).toBe('second')
455
+ expect(result.proxyHeaders['third-header']).toBe('third')
456
+ })
457
+
458
+ it('should handle X-Forwarded-For with whitespace variations', () => {
459
+ const req = {
460
+ headers: {
461
+ 'x-forwarded-for': '10.0.0.1 , 10.0.0.2, 10.0.0.3',
462
+ host: 'source.com',
463
+ },
464
+ socket: {
465
+ remoteAddress: '192.168.1.100',
466
+ },
467
+ } as unknown as IncomingMessage
468
+
469
+ const headers = processor.buildForwardedHeaders(req, 'target.com')
470
+ expect(headers['X-Forwarded-For']).toBe('10.0.0.1, 10.0.0.2, 10.0.0.3, 192.168.1.100')
471
+ })
472
+
473
+ it('should handle cookie transformer returning empty array', () => {
474
+ const req = {
475
+ headers: {
476
+ cookie: 'session=abc123',
477
+ },
478
+ socket: {
479
+ remoteAddress: '192.168.1.100',
480
+ },
481
+ } as unknown as IncomingMessage
482
+
483
+ const result = processor.processRequestHeaders(req, 'target.com', {
484
+ cookies: () => [],
485
+ })
486
+
487
+ expect(result.proxyHeaders.Cookie).toBeUndefined()
488
+ expect(result.finalCookies).toEqual([])
489
+ })
490
+
491
+ it('should handle header transformer removing all headers', () => {
492
+ const req = {
493
+ headers: {
494
+ 'content-type': 'application/json',
495
+ authorization: 'Bearer token',
496
+ },
497
+ socket: {
498
+ remoteAddress: '192.168.1.100',
499
+ },
500
+ } as unknown as IncomingMessage
501
+
502
+ const result = processor.processRequestHeaders(req, 'target.com', {
503
+ headers: () => ({}),
504
+ })
505
+
506
+ // Should still have forwarded headers
507
+ expect(result.proxyHeaders.Host).toBe('target.com')
508
+ expect(result.proxyHeaders['X-Forwarded-For']).toBeTruthy()
509
+ // But not the original headers
510
+ expect(result.proxyHeaders['content-type']).toBeUndefined()
511
+ expect(result.proxyHeaders.authorization).toBeUndefined()
512
+ })
513
+ })
514
+ })
@@ -0,0 +1,140 @@
1
+ import type { IncomingMessage, OutgoingHttpHeaders } from 'http'
2
+
3
+ // Headers that should not be forwarded to the target server
4
+ const HOP_BY_HOP_HEADERS = new Set([
5
+ 'connection',
6
+ 'keep-alive',
7
+ 'proxy-authenticate',
8
+ 'proxy-authorization',
9
+ 'te',
10
+ 'trailer',
11
+ 'transfer-encoding',
12
+ 'upgrade',
13
+ ])
14
+
15
+ export interface HeaderProcessorOptions {
16
+ headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
17
+ cookies?: (originalCookies: string[]) => string[]
18
+ }
19
+
20
+ export interface ProcessedHeaders {
21
+ proxyHeaders: Record<string, string>
22
+ finalCookies: string[]
23
+ }
24
+
25
+ /**
26
+ * Handles header filtering, transformation, and cookie processing for proxy requests
27
+ */
28
+ export class HeaderProcessor {
29
+ /**
30
+ * Filters out hop-by-hop headers that should not be forwarded
31
+ */
32
+ public filterHeaders(headers: OutgoingHttpHeaders): OutgoingHttpHeaders {
33
+ const filtered: OutgoingHttpHeaders = {}
34
+ for (const [key, value] of Object.entries(headers)) {
35
+ if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) {
36
+ filtered[key] = value
37
+ }
38
+ }
39
+ return filtered
40
+ }
41
+
42
+ /**
43
+ * Processes cookies from the request
44
+ */
45
+ public processCookies(req: IncomingMessage, cookieTransformer?: (cookies: string[]) => string[]): string[] {
46
+ const originalCookies = req.headers.cookie?.split(';').map((c) => c.trim()) ?? []
47
+ return cookieTransformer ? cookieTransformer(originalCookies) : originalCookies
48
+ }
49
+
50
+ /**
51
+ * Builds X-Forwarded-* headers
52
+ */
53
+ public buildForwardedHeaders(req: IncomingMessage, targetHost: string): Record<string, string> {
54
+ const forwardedFor = [
55
+ (req.headers['x-forwarded-for'] as string | undefined)
56
+ ?.split(',')
57
+ .map((s) => s.trim())
58
+ .filter(Boolean) ?? [],
59
+ [req.socket.remoteAddress ?? ''],
60
+ ]
61
+ .flat()
62
+ .filter(Boolean)
63
+ .join(', ')
64
+
65
+ return {
66
+ Host: targetHost,
67
+ 'User-Agent': (req.headers['user-agent'] as string) || 'FuryStack-Proxy/1.0',
68
+ 'X-Forwarded-For': forwardedFor,
69
+ 'X-Forwarded-Host': (req.headers.host as string) || '',
70
+ 'X-Forwarded-Proto':
71
+ (req.headers['x-forwarded-proto'] as string) ||
72
+ ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http'),
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Converts OutgoingHttpHeaders to a plain string record, excluding cookies and hop-by-hop headers
78
+ */
79
+ public convertHeadersToRecord(headers: OutgoingHttpHeaders): Record<string, string> {
80
+ const result: Record<string, string> = {}
81
+ for (const [key, value] of Object.entries(headers)) {
82
+ const lowerKey = key.toLowerCase()
83
+ if (lowerKey !== 'cookie' && !HOP_BY_HOP_HEADERS.has(lowerKey)) {
84
+ if (typeof value === 'string') {
85
+ result[key] = value
86
+ } else if (typeof value === 'number') {
87
+ result[key] = value.toString()
88
+ } else if (Array.isArray(value)) {
89
+ result[key] = value.join(', ')
90
+ }
91
+ // undefined is intentionally skipped
92
+ }
93
+ }
94
+ return result
95
+ }
96
+
97
+ /**
98
+ * Processes all request headers and returns proxy-ready headers
99
+ */
100
+ public processRequestHeaders(
101
+ req: IncomingMessage,
102
+ targetHost: string,
103
+ options: HeaderProcessorOptions = {},
104
+ ): ProcessedHeaders {
105
+ // Extract and normalize original headers
106
+ const originalHeaders: OutgoingHttpHeaders = {}
107
+ for (const [key, value] of Object.entries(req.headers)) {
108
+ originalHeaders[key] = Array.isArray(value) ? value.join(', ') : value
109
+ }
110
+
111
+ // Filter and transform headers
112
+ const filteredHeaders = this.filterHeaders(originalHeaders)
113
+ const finalHeaders = options.headers ? options.headers(filteredHeaders) : filteredHeaders
114
+
115
+ // Process cookies
116
+ const finalCookies = this.processCookies(req, options.cookies)
117
+
118
+ // Build proxy headers
119
+ const headersWithoutCookie = this.convertHeadersToRecord(finalHeaders)
120
+ const forwardedHeaders = this.buildForwardedHeaders(req, targetHost)
121
+
122
+ const proxyHeaders: Record<string, string> = {
123
+ ...headersWithoutCookie,
124
+ ...forwardedHeaders,
125
+ }
126
+
127
+ if (finalCookies.length > 0) {
128
+ proxyHeaders.Cookie = finalCookies.join('; ')
129
+ }
130
+
131
+ return { proxyHeaders, finalCookies }
132
+ }
133
+
134
+ /**
135
+ * Filters hop-by-hop headers from response headers
136
+ */
137
+ public isHopByHopHeader(headerName: string): boolean {
138
+ return HOP_BY_HOP_HEADERS.has(headerName.toLowerCase())
139
+ }
140
+ }
@@ -1,11 +1,12 @@
1
+ import { getPort } from '@furystack/core/port-generator'
1
2
  import { Injector } from '@furystack/inject'
2
3
  import { usingAsync } from '@furystack/utils'
4
+ import { describe, expect, it } from 'vitest'
3
5
  import { ApiManager } from './api-manager.js'
6
+ import { useHttpAuthentication, useProxy, useRestService, useStaticFiles } from './helpers.js'
4
7
  import { HttpAuthenticationSettings } from './http-authentication-settings.js'
5
- import { useHttpAuthentication, useRestService, useStaticFiles } from './helpers.js'
8
+ import { ProxyManager } from './proxy-manager.js'
6
9
  import { StaticServerManager } from './static-server-manager.js'
7
- import { describe, it, expect } from 'vitest'
8
- import { getPort } from '@furystack/core/port-generator'
9
10
 
10
11
  describe('Injector extensions', () => {
11
12
  describe('useHttpAuthentication', () => {
@@ -28,8 +29,8 @@ describe('Injector extensions', () => {
28
29
  })
29
30
  })
30
31
 
31
- describe('useRestService()', () => {
32
- it('Should set up a REST service', async () => {
32
+ describe('useStaticFiles()', () => {
33
+ it('Should set up a static file server', async () => {
33
34
  await usingAsync(new Injector(), async (i) => {
34
35
  const port = getPort()
35
36
 
@@ -38,4 +39,21 @@ describe('Injector extensions', () => {
38
39
  })
39
40
  })
40
41
  })
42
+
43
+ describe('useProxy()', () => {
44
+ it('Should set up a proxy server', async () => {
45
+ await usingAsync(new Injector(), async (i) => {
46
+ const sourcePort = getPort()
47
+ const targetPort = getPort()
48
+
49
+ await useProxy({
50
+ injector: i,
51
+ sourceBaseUrl: '/api',
52
+ targetBaseUrl: `http://localhost:${targetPort}`,
53
+ sourcePort,
54
+ })
55
+ expect(i.cachedSingletons.get(ProxyManager)).toBeDefined()
56
+ })
57
+ })
58
+ })
41
59
  })