@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.
- package/README.md +249 -12
- package/esm/api-manager.d.ts +1 -3
- package/esm/api-manager.d.ts.map +1 -1
- package/esm/api-manager.js +4 -6
- package/esm/api-manager.js.map +1 -1
- package/esm/header-processor.d.ts +39 -0
- package/esm/header-processor.d.ts.map +1 -0
- package/esm/header-processor.js +113 -0
- package/esm/header-processor.js.map +1 -0
- package/esm/header-processor.spec.d.ts +2 -0
- package/esm/header-processor.spec.d.ts.map +1 -0
- package/esm/header-processor.spec.js +420 -0
- package/esm/header-processor.spec.js.map +1 -0
- package/esm/helpers.d.ts +69 -1
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +70 -1
- package/esm/helpers.js.map +1 -1
- package/esm/helpers.spec.js +21 -5
- package/esm/helpers.spec.js.map +1 -1
- package/esm/http-proxy-handler.d.ts +53 -0
- package/esm/http-proxy-handler.d.ts.map +1 -0
- package/esm/http-proxy-handler.js +179 -0
- package/esm/http-proxy-handler.js.map +1 -0
- package/esm/http-user-context.d.ts +4 -4
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +4 -4
- package/esm/http-user-context.js.map +1 -1
- package/esm/index.d.ts +1 -0
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -0
- package/esm/index.js.map +1 -1
- package/esm/path-processor.d.ts +33 -0
- package/esm/path-processor.d.ts.map +1 -0
- package/esm/path-processor.js +58 -0
- package/esm/path-processor.js.map +1 -0
- package/esm/path-processor.spec.d.ts +2 -0
- package/esm/path-processor.spec.d.ts.map +1 -0
- package/esm/path-processor.spec.js +256 -0
- package/esm/path-processor.spec.js.map +1 -0
- package/esm/proxy-manager.d.ts +52 -0
- package/esm/proxy-manager.d.ts.map +1 -0
- package/esm/proxy-manager.js +84 -0
- package/esm/proxy-manager.js.map +1 -0
- package/esm/proxy-manager.spec.d.ts +2 -0
- package/esm/proxy-manager.spec.d.ts.map +1 -0
- package/esm/proxy-manager.spec.js +1781 -0
- package/esm/proxy-manager.spec.js.map +1 -0
- package/esm/server-manager.d.ts +7 -0
- package/esm/server-manager.d.ts.map +1 -1
- package/esm/server-manager.js +12 -0
- package/esm/server-manager.js.map +1 -1
- package/esm/static-server-manager.d.ts.map +1 -1
- package/esm/static-server-manager.js +5 -7
- package/esm/static-server-manager.js.map +1 -1
- package/esm/websocket-proxy-handler.d.ts +44 -0
- package/esm/websocket-proxy-handler.d.ts.map +1 -0
- package/esm/websocket-proxy-handler.js +157 -0
- package/esm/websocket-proxy-handler.js.map +1 -0
- package/package.json +12 -10
- package/src/api-manager.ts +5 -15
- package/src/header-processor.spec.ts +514 -0
- package/src/header-processor.ts +140 -0
- package/src/helpers.spec.ts +23 -5
- package/src/helpers.ts +72 -1
- package/src/http-proxy-handler.ts +215 -0
- package/src/http-user-context.ts +6 -6
- package/src/index.ts +1 -0
- package/src/path-processor.spec.ts +318 -0
- package/src/path-processor.ts +69 -0
- package/src/proxy-manager.spec.ts +2094 -0
- package/src/proxy-manager.ts +101 -0
- package/src/server-manager.ts +19 -0
- package/src/static-server-manager.ts +5 -7
- 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
|
+
}
|
package/src/helpers.spec.ts
CHANGED
|
@@ -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 {
|
|
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('
|
|
32
|
-
it('Should set up a
|
|
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
|
})
|