@fastify/reply-from 12.4.0 → 12.6.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 CHANGED
@@ -524,26 +524,34 @@ This is due to the fact that `@fastify/multipart` consumes the multipart content
524
524
 
525
525
  However, the two plugins may be used within the same fastify instance, at the condition that they belong to disjoint branches of the fastify plugins hierarchy tree.
526
526
 
527
- ### Proxying multipart/form-data without @fastify/multipart
527
+ ### Proxying specific content types without parsing
528
528
 
529
- If you need to proxy `multipart/form-data` requests without parsing them, you can use a custom content type parser instead of `@fastify/multipart`:
529
+ If you need to proxy certain content types (like `multipart/form-data` or `text/event-stream`) without parsing them, you can use custom content type parsers:
530
530
 
531
531
  ```js
532
- // Register a custom content type parser for multipart/form-data
533
- // This passes the raw body through without parsing
532
+ // Register custom content type parsers that pass raw body through
534
533
  fastify.addContentTypeParser('multipart/form-data', function (req, body, done) {
535
534
  done(null, body)
536
535
  })
537
536
 
537
+ fastify.addContentTypeParser('text/event-stream', function (req, body, done) {
538
+ done(null, body)
539
+ })
540
+
538
541
  fastify.register(require('@fastify/reply-from'))
539
542
 
540
543
  fastify.post('/upload', (request, reply) => {
541
544
  // The multipart data will be proxied as-is to the upstream server
542
545
  reply.from('http://upstream-server.com/upload')
543
546
  })
547
+
548
+ fastify.post('/events', (request, reply) => {
549
+ // The SSE data will be proxied as-is to the upstream server
550
+ reply.from('http://upstream-server.com/events')
551
+ })
544
552
  ```
545
553
 
546
- This approach allows `multipart/form-data` to be proxied correctly while avoiding the incompatibility with `@fastify/multipart`.
554
+ This approach allows these content types to be proxied correctly while avoiding parsing that would consume the request body.
547
555
 
548
556
  ## License
549
557
 
package/index.js CHANGED
@@ -71,7 +71,9 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
71
71
  const retryDelay = opts.retryDelay || undefined
72
72
 
73
73
  if (!source) {
74
- source = req.url
74
+ const requestUrl = req.url
75
+ const queryIndex = requestUrl.indexOf('?')
76
+ source = queryIndex >= 0 ? requestUrl.substring(0, queryIndex) : requestUrl
75
77
  }
76
78
 
77
79
  // we leverage caching to avoid parsing the destination URL
package/lib/request.js CHANGED
@@ -5,7 +5,7 @@ const querystring = require('node:querystring')
5
5
  const eos = require('end-of-stream')
6
6
  const { pipeline } = require('node:stream')
7
7
  const undici = require('undici')
8
- const { stripHttp1ConnectionHeaders } = require('./utils')
8
+ const { stripHttp1ConnectionHeaders, getConnectionHeaders } = require('./utils')
9
9
  const http2 = require('node:http2')
10
10
 
11
11
  const {
@@ -53,7 +53,7 @@ function buildRequest (opts) {
53
53
  const isBalanced = Array.isArray(opts.base) && opts.base.length > 1
54
54
  const undiciOpts = opts.undici || {}
55
55
  const globalAgent = opts.globalAgent
56
- const destroyAgent = opts.destroyAgent
56
+ const destroyAgent = opts.destroyAgent || false
57
57
  let http2Client
58
58
  let undiciAgent
59
59
  let undiciInstance
@@ -104,6 +104,11 @@ function buildRequest (opts) {
104
104
  }
105
105
 
106
106
  function close () {
107
+ // Always destroy http2 client (internal implementation detail)
108
+ if (http2Client) {
109
+ http2Client.destroy()
110
+ }
111
+
107
112
  if (globalAgent || destroyAgent === false) {
108
113
  return
109
114
  }
@@ -114,18 +119,28 @@ function buildRequest (opts) {
114
119
  } else if (!isHttp2) {
115
120
  agents['http:'].destroy()
116
121
  agents['https:'].destroy()
117
- } else if (http2Client) {
118
- http2Client.destroy()
119
122
  }
120
123
  }
121
124
 
122
125
  function handleHttp1Req (opts, done) {
126
+ // Strip Connection header and headers listed in it (RFC 7230 Section 6.1)
127
+ // Headers are already lowercased by Node.js
128
+ const connectionHeaderNames = getConnectionHeaders(opts.headers)
129
+ let headers = opts.headers
130
+ if (opts.headers.connection || connectionHeaderNames.length > 0) {
131
+ headers = { ...opts.headers }
132
+ delete headers.connection
133
+ for (let i = 0; i < connectionHeaderNames.length; i++) {
134
+ delete headers[connectionHeaderNames[i]]
135
+ }
136
+ }
137
+
123
138
  const req = requests[opts.url.protocol].request({
124
139
  method: opts.method,
125
140
  port: opts.url.port,
126
141
  path: opts.url.pathname + opts.qs,
127
142
  hostname: opts.url.hostname,
128
- headers: opts.headers,
143
+ headers,
129
144
  agent: agents[opts.url.protocol.replace(/^unix:/, '')],
130
145
  ...httpOpts.requestOptions,
131
146
  timeout: opts.timeout ?? httpOpts.requestOptions.timeout
@@ -171,10 +186,19 @@ function buildRequest (opts) {
171
186
  pool = undiciAgent
172
187
  }
173
188
 
189
+ // Strip headers listed in Connection header (RFC 7230 Section 6.1)
190
+ // Headers are already lowercased
191
+ const connectionHeaderNames = getConnectionHeaders(req.headers)
192
+
174
193
  // remove forbidden headers
175
194
  req.headers.connection = undefined
176
195
  req.headers['transfer-encoding'] = undefined
177
196
 
197
+ // Also remove headers listed in Connection header
198
+ for (let i = 0; i < connectionHeaderNames.length; i++) {
199
+ req.headers[connectionHeaderNames[i]] = undefined
200
+ }
201
+
178
202
  pool.request(req, function (err, res) {
179
203
  if (err) {
180
204
  done(err)
package/lib/utils.js CHANGED
@@ -28,10 +28,36 @@ function copyHeaders (headers, reply) {
28
28
  }
29
29
  }
30
30
 
31
+ // Parse Connection header and return list of header names to strip (per RFC 7230 Section 6.1)
32
+ function getConnectionHeaders (headers) {
33
+ const connectionHeader = headers.connection
34
+ if (typeof connectionHeader !== 'string') {
35
+ return []
36
+ }
37
+ // Connection header is comma-separated list of header names
38
+ const lowerCased = connectionHeader.toLowerCase()
39
+ const result = []
40
+ let start = 0
41
+ let end = 0
42
+ for (; end <= lowerCased.length; end++) {
43
+ if (lowerCased.charCodeAt(end) === 44 || end === lowerCased.length) { // 44 = ','
44
+ const token = lowerCased.slice(start, end).trim()
45
+ if (token.length > 0) {
46
+ result.push(token)
47
+ }
48
+ start = end + 1
49
+ }
50
+ }
51
+ return result
52
+ }
53
+
31
54
  function stripHttp1ConnectionHeaders (headers) {
32
55
  const headersKeys = Object.keys(headers)
33
56
  const dest = {}
34
57
 
58
+ // Get headers listed in Connection header that should be stripped (RFC 7230 Section 6.1)
59
+ const connectionHeaderNames = getConnectionHeaders(headers)
60
+
35
61
  let header
36
62
  let i
37
63
 
@@ -54,7 +80,10 @@ function stripHttp1ConnectionHeaders (headers) {
54
80
  }
55
81
  break
56
82
  default:
57
- dest[header] = headers[header]
83
+ // Also skip headers listed in Connection header (RFC 7230 Section 6.1)
84
+ if (!connectionHeaderNames.includes(header)) {
85
+ dest[header] = headers[header]
86
+ }
58
87
  break
59
88
  }
60
89
  }
@@ -63,6 +92,12 @@ function stripHttp1ConnectionHeaders (headers) {
63
92
 
64
93
  // issue ref: https://github.com/fastify/fast-proxy/issues/42
65
94
  function buildURL (source, reqBase) {
95
+ if (decodeURIComponent(source).includes('..')) {
96
+ const err = new Error('source/request contain invalid characters')
97
+ err.statusCode = 400
98
+ throw err
99
+ }
100
+
66
101
  if (Array.isArray(reqBase)) reqBase = reqBase[0]
67
102
  let baseOrigin = reqBase ? new URL(reqBase).href : undefined
68
103
 
@@ -90,6 +125,7 @@ function buildURL (source, reqBase) {
90
125
  module.exports = {
91
126
  copyHeaders,
92
127
  stripHttp1ConnectionHeaders,
128
+ getConnectionHeaders,
93
129
  filterPseudoHeaders,
94
130
  buildURL
95
131
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/reply-from",
3
- "version": "12.4.0",
3
+ "version": "12.6.0",
4
4
  "description": "forward your HTTP request to another server, for fastify",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -60,8 +60,8 @@
60
60
  "devDependencies": {
61
61
  "@fastify/formbody": "^8.0.0",
62
62
  "@fastify/multipart": "^9.0.0",
63
- "@sinonjs/fake-timers": "^14.0.0",
64
- "@types/node": "^24.0.8",
63
+ "@sinonjs/fake-timers": "^15.0.0",
64
+ "@types/node": "^25.0.3",
65
65
  "@types/tap": "^18.0.0",
66
66
  "c8": "^10.1.3",
67
67
  "eslint": "^9.17.0",
@@ -76,3 +76,35 @@ test('should throw when trying to override base', async (t) => {
76
76
 
77
77
  await Promise.all(promises)
78
78
  })
79
+
80
+ test('should throw on path traversal attempts', (t) => {
81
+ t.assert.throws(
82
+ () => buildURL('/foo/bar/../', 'http://localhost'),
83
+ new Error('source/request contain invalid characters')
84
+ )
85
+
86
+ t.assert.throws(
87
+ () => buildURL('/foo/bar/..', 'http://localhost'),
88
+ new Error('source/request contain invalid characters')
89
+ )
90
+
91
+ t.assert.throws(
92
+ () => buildURL('/foo/bar/%2e%2e/', 'http://localhost'),
93
+ new Error('source/request contain invalid characters')
94
+ )
95
+
96
+ t.assert.throws(
97
+ () => buildURL('/foo/bar/%2E%2E/', 'http://localhost'),
98
+ new Error('source/request contain invalid characters')
99
+ )
100
+
101
+ t.assert.throws(
102
+ () => buildURL('/foo/bar/..%2f', 'http://localhost'),
103
+ new Error('source/request contain invalid characters')
104
+ )
105
+
106
+ t.assert.throws(
107
+ () => buildURL('/foo/bar/%2e%2e%2f', 'http://localhost'),
108
+ new Error('source/request contain invalid characters')
109
+ )
110
+ })
@@ -9,14 +9,14 @@ const http = require('node:http')
9
9
  const instance = Fastify()
10
10
 
11
11
  t.test('core with path in base', async (t) => {
12
- t.plan(8)
12
+ t.plan(7)
13
13
  t.after(() => instance.close())
14
14
 
15
15
  const target = http.createServer((req, res) => {
16
16
  t.assert.ok('request proxied')
17
17
  t.assert.strictEqual(req.method, 'GET')
18
18
  t.assert.strictEqual(req.url, '/hello')
19
- t.assert.strictEqual(req.headers.connection, 'close')
19
+ // Connection header is not forwarded per RFC 7230 Section 6.1
20
20
  res.statusCode = 205
21
21
  res.setHeader('Content-Type', 'text/plain')
22
22
  res.setHeader('x-my-header', 'hello!')
@@ -0,0 +1,32 @@
1
+ 'use strict'
2
+
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const { request } = require('undici')
6
+ const From = require('..')
7
+ const http = require('node:http')
8
+
9
+ const instance = Fastify()
10
+
11
+ t.test('fix for GHSA-2q7r-29rg-6m5h vulnerability', async (t) => {
12
+ t.plan(2)
13
+
14
+ const target = http.createServer((_, res) => {
15
+ res.statusCode = 205
16
+ res.end('hi')
17
+ })
18
+ await target.listen({ port: 0 })
19
+ t.after(() => target.close())
20
+
21
+ instance.get('/', (_request, reply) => { reply.from('/ho/%2E%2E/hi') })
22
+ instance.register(From, {
23
+ base: `http://localhost:${target.address().port}/hi/`,
24
+ undici: true
25
+ })
26
+ await instance.listen({ port: 0 })
27
+ t.after(() => instance.close())
28
+
29
+ const { statusCode, body } = await request(`http://localhost:${instance.server.address().port}`)
30
+ t.assert.strictEqual(statusCode, 400)
31
+ t.assert.strictEqual(await body.text(), '{"statusCode":400,"error":"Bad Request","message":"source/request contain invalid characters"}')
32
+ })
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const { request } = require('undici')
6
+ const From = require('..')
7
+ const http = require('node:http')
8
+
9
+ const instance = Fastify()
10
+
11
+ t.test('full querystring url', async (t) => {
12
+ const target = http.createServer((req, res) => {
13
+ t.assert.ok('request proxied')
14
+ t.assert.strictEqual(req.method, 'GET')
15
+ t.assert.strictEqual(req.url, '/hi?a=/ho/%2E%2E/hi')
16
+ res.statusCode = 205
17
+ res.setHeader('Content-Type', 'text/plain')
18
+ res.setHeader('x-my-header', 'hi!')
19
+ res.end('hi')
20
+ })
21
+
22
+ await target.listen({ port: 0 })
23
+ t.after(() => target.close())
24
+
25
+ await instance.register(From, {
26
+ base: `http://localhost:${target.address().port}`
27
+ })
28
+
29
+ instance.get('/hi', (_request, reply) => {
30
+ reply.from()
31
+ })
32
+
33
+ instance.get('/foo', (_request, reply) => {
34
+ reply.from('/hi')
35
+ })
36
+
37
+ await instance.listen({ port: 0 })
38
+ t.after(() => instance.close())
39
+
40
+ {
41
+ const result = await request(`http://localhost:${instance.server.address().port}/hi?a=/ho/%2E%2E/hi`)
42
+ t.assert.strictEqual(result.headers['content-type'], 'text/plain')
43
+ t.assert.strictEqual(result.headers['x-my-header'], 'hi!')
44
+ t.assert.strictEqual(result.statusCode, 205)
45
+ t.assert.strictEqual(await result.body.text(), 'hi')
46
+ }
47
+
48
+ {
49
+ const result = await request(`http://localhost:${instance.server.address().port}/foo?a=/ho/%2E%2E/hi`)
50
+ t.assert.strictEqual(result.headers['content-type'], 'text/plain')
51
+ t.assert.strictEqual(result.headers['x-my-header'], 'hi!')
52
+ t.assert.strictEqual(result.statusCode, 205)
53
+ t.assert.strictEqual(await result.body.text(), 'hi')
54
+ }
55
+ })
@@ -0,0 +1,232 @@
1
+ 'use strict'
2
+
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const From = require('..')
6
+ const http = require('node:http')
7
+
8
+ // RFC 7230 Section 6.1 - Connection header handling
9
+ // A proxy MUST parse the Connection header and remove any headers listed within it
10
+
11
+ // Helper to make HTTP request with Connection header (undici doesn't allow this)
12
+ function makeRequest (port, headers) {
13
+ return new Promise((resolve, reject) => {
14
+ const req = http.request({
15
+ method: 'GET',
16
+ hostname: 'localhost',
17
+ port,
18
+ path: '/',
19
+ headers
20
+ }, (res) => {
21
+ let data = ''
22
+ res.on('data', (chunk) => { data += chunk })
23
+ res.on('end', () => resolve({ statusCode: res.statusCode, body: data }))
24
+ })
25
+ req.on('error', reject)
26
+ req.end()
27
+ })
28
+ }
29
+
30
+ t.test('strips headers listed in Connection header (undici)', async (t) => {
31
+ t.plan(4)
32
+ const instance = Fastify()
33
+ instance.register(From)
34
+
35
+ t.after(() => instance.close())
36
+
37
+ const target = http.createServer((req, res) => {
38
+ t.assert.ok('request proxied')
39
+ t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
40
+ res.statusCode = 200
41
+ res.setHeader('Content-Type', 'text/plain')
42
+ res.end('ok')
43
+ })
44
+
45
+ instance.get('/', (_request, reply) => {
46
+ reply.from(`http://localhost:${target.address().port}`)
47
+ })
48
+
49
+ t.after(() => target.close())
50
+
51
+ await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
52
+ await new Promise((resolve) => target.listen({ port: 0 }, resolve))
53
+
54
+ const result = await makeRequest(instance.server.address().port, {
55
+ 'X-Custom-Header': 'some-value',
56
+ Connection: 'X-Custom-Header'
57
+ })
58
+
59
+ t.assert.strictEqual(result.statusCode, 200)
60
+ t.assert.strictEqual(result.body, 'ok')
61
+ })
62
+
63
+ t.test('strips multiple headers listed in Connection header (undici)', async (t) => {
64
+ t.plan(5)
65
+ const instance = Fastify()
66
+ instance.register(From)
67
+
68
+ t.after(() => instance.close())
69
+
70
+ const target = http.createServer((req, res) => {
71
+ t.assert.ok('request proxied')
72
+ t.assert.strictEqual(req.headers['x-custom-one'], undefined, 'X-Custom-One should be stripped')
73
+ t.assert.strictEqual(req.headers['x-custom-two'], undefined, 'X-Custom-Two should be stripped')
74
+ res.statusCode = 200
75
+ res.setHeader('Content-Type', 'text/plain')
76
+ res.end('ok')
77
+ })
78
+
79
+ instance.get('/', (_request, reply) => {
80
+ reply.from(`http://localhost:${target.address().port}`)
81
+ })
82
+
83
+ t.after(() => target.close())
84
+
85
+ await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
86
+ await new Promise((resolve) => target.listen({ port: 0 }, resolve))
87
+
88
+ const result = await makeRequest(instance.server.address().port, {
89
+ 'X-Custom-One': 'value1',
90
+ 'X-Custom-Two': 'value2',
91
+ Connection: 'X-Custom-One, X-Custom-Two'
92
+ })
93
+
94
+ t.assert.strictEqual(result.statusCode, 200)
95
+ t.assert.strictEqual(result.body, 'ok')
96
+ })
97
+
98
+ t.test('preserves headers not listed in Connection header (undici)', async (t) => {
99
+ t.plan(5)
100
+ const instance = Fastify()
101
+ instance.register(From)
102
+
103
+ t.after(() => instance.close())
104
+
105
+ const target = http.createServer((req, res) => {
106
+ t.assert.ok('request proxied')
107
+ t.assert.strictEqual(req.headers['x-keep-header'], 'keep-me', 'X-Keep-Header should be preserved')
108
+ t.assert.strictEqual(req.headers['x-strip-header'], undefined, 'X-Strip-Header should be stripped')
109
+ res.statusCode = 200
110
+ res.setHeader('Content-Type', 'text/plain')
111
+ res.end('ok')
112
+ })
113
+
114
+ instance.get('/', (_request, reply) => {
115
+ reply.from(`http://localhost:${target.address().port}`)
116
+ })
117
+
118
+ t.after(() => target.close())
119
+
120
+ await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
121
+ await new Promise((resolve) => target.listen({ port: 0 }, resolve))
122
+
123
+ const result = await makeRequest(instance.server.address().port, {
124
+ 'X-Keep-Header': 'keep-me',
125
+ 'X-Strip-Header': 'strip-me',
126
+ Connection: 'X-Strip-Header'
127
+ })
128
+
129
+ t.assert.strictEqual(result.statusCode, 200)
130
+ t.assert.strictEqual(result.body, 'ok')
131
+ })
132
+
133
+ t.test('strips headers listed in Connection header (http)', async (t) => {
134
+ t.plan(4)
135
+ const instance = Fastify()
136
+ instance.register(From, { undici: false })
137
+
138
+ t.after(() => instance.close())
139
+
140
+ const target = http.createServer((req, res) => {
141
+ t.assert.ok('request proxied')
142
+ t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
143
+ res.statusCode = 200
144
+ res.setHeader('Content-Type', 'text/plain')
145
+ res.end('ok')
146
+ })
147
+
148
+ instance.get('/', (_request, reply) => {
149
+ reply.from(`http://localhost:${target.address().port}`)
150
+ })
151
+
152
+ t.after(() => target.close())
153
+
154
+ await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
155
+ await new Promise((resolve) => target.listen({ port: 0 }, resolve))
156
+
157
+ const result = await makeRequest(instance.server.address().port, {
158
+ 'X-Custom-Header': 'some-value',
159
+ Connection: 'X-Custom-Header'
160
+ })
161
+
162
+ t.assert.strictEqual(result.statusCode, 200)
163
+ t.assert.strictEqual(result.body, 'ok')
164
+ })
165
+
166
+ t.test('strips multiple headers listed in Connection header (http)', async (t) => {
167
+ t.plan(5)
168
+ const instance = Fastify()
169
+ instance.register(From, { undici: false })
170
+
171
+ t.after(() => instance.close())
172
+
173
+ const target = http.createServer((req, res) => {
174
+ t.assert.ok('request proxied')
175
+ t.assert.strictEqual(req.headers['x-custom-one'], undefined, 'X-Custom-One should be stripped')
176
+ t.assert.strictEqual(req.headers['x-custom-two'], undefined, 'X-Custom-Two should be stripped')
177
+ res.statusCode = 200
178
+ res.setHeader('Content-Type', 'text/plain')
179
+ res.end('ok')
180
+ })
181
+
182
+ instance.get('/', (_request, reply) => {
183
+ reply.from(`http://localhost:${target.address().port}`)
184
+ })
185
+
186
+ t.after(() => target.close())
187
+
188
+ await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
189
+ await new Promise((resolve) => target.listen({ port: 0 }, resolve))
190
+
191
+ const result = await makeRequest(instance.server.address().port, {
192
+ 'X-Custom-One': 'value1',
193
+ 'X-Custom-Two': 'value2',
194
+ Connection: 'X-Custom-One, X-Custom-Two'
195
+ })
196
+
197
+ t.assert.strictEqual(result.statusCode, 200)
198
+ t.assert.strictEqual(result.body, 'ok')
199
+ })
200
+
201
+ t.test('handles Connection header with keep-alive and custom headers (undici)', async (t) => {
202
+ t.plan(4)
203
+ const instance = Fastify()
204
+ instance.register(From)
205
+
206
+ t.after(() => instance.close())
207
+
208
+ const target = http.createServer((req, res) => {
209
+ t.assert.ok('request proxied')
210
+ t.assert.strictEqual(req.headers['x-custom-header'], undefined, 'X-Custom-Header should be stripped')
211
+ res.statusCode = 200
212
+ res.setHeader('Content-Type', 'text/plain')
213
+ res.end('ok')
214
+ })
215
+
216
+ instance.get('/', (_request, reply) => {
217
+ reply.from(`http://localhost:${target.address().port}`)
218
+ })
219
+
220
+ t.after(() => target.close())
221
+
222
+ await new Promise((resolve) => instance.listen({ port: 0 }, resolve))
223
+ await new Promise((resolve) => target.listen({ port: 0 }, resolve))
224
+
225
+ const result = await makeRequest(instance.server.address().port, {
226
+ 'X-Custom-Header': 'some-value',
227
+ Connection: 'keep-alive, X-Custom-Header'
228
+ })
229
+
230
+ t.assert.strictEqual(result.statusCode, 200)
231
+ t.assert.strictEqual(result.body, 'ok')
232
+ })
@@ -0,0 +1,68 @@
1
+ 'use strict'
2
+
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const { request } = require('undici')
6
+ const From = require('..')
7
+ const http = require('node:http')
8
+
9
+ t.test('text/event-stream proxying with custom content type parser', async (t) => {
10
+ t.plan(6)
11
+
12
+ // Target server that sends SSE data
13
+ const target = http.createServer((req, res) => {
14
+ t.assert.ok('request proxied')
15
+ t.assert.strictEqual(req.method, 'POST')
16
+ t.assert.match(req.headers['content-type'], /^text\/event-stream/)
17
+
18
+ let data = ''
19
+ req.setEncoding('utf8')
20
+ req.on('data', (chunk) => {
21
+ data += chunk
22
+ })
23
+ req.on('end', () => {
24
+ // Verify the SSE data is received
25
+ t.assert.match(data, /data: test message/)
26
+ t.assert.match(data, /event: custom/)
27
+
28
+ res.setHeader('content-type', 'application/json')
29
+ res.statusCode = 200
30
+ res.end(JSON.stringify({ received: 'sse data' }))
31
+ })
32
+ })
33
+
34
+ // Fastify instance with custom text/event-stream parser
35
+ const fastify = Fastify()
36
+
37
+ // Register custom content type parser for text/event-stream
38
+ // This allows the raw body to be passed through without parsing
39
+ fastify.addContentTypeParser('text/event-stream', function (req, body, done) {
40
+ done(null, body)
41
+ })
42
+
43
+ fastify.register(From)
44
+
45
+ fastify.post('/', (request, reply) => {
46
+ reply.from(`http://localhost:${target.address().port}`)
47
+ })
48
+
49
+ t.after(() => fastify.close())
50
+ t.after(() => target.close())
51
+
52
+ await fastify.listen({ port: 0 })
53
+ await target.listen({ port: 0 })
54
+
55
+ // Create SSE-like data
56
+ const sseData = 'data: test message\nevent: custom\ndata: another line\n\n'
57
+
58
+ // Send request with SSE data
59
+ const result = await request(`http://localhost:${fastify.server.address().port}`, {
60
+ method: 'POST',
61
+ headers: {
62
+ 'content-type': 'text/event-stream'
63
+ },
64
+ body: sseData
65
+ })
66
+
67
+ t.assert.deepStrictEqual(await result.body.json(), { received: 'sse data' })
68
+ })
@@ -27,3 +27,25 @@ test('destroyAgent false', async (t) => {
27
27
  await instance.ready()
28
28
  await instance.close()
29
29
  })
30
+
31
+ test('destroyAgent default false', async (t) => {
32
+ const mockAgent = new undici.Agent()
33
+ mockAgent.destroy = () => {
34
+ t.fail()
35
+ }
36
+ const instance = Fastify()
37
+
38
+ t.after(() => instance.close())
39
+
40
+ instance.get('/', (_request, reply) => {
41
+ reply.from()
42
+ })
43
+
44
+ instance.register(From, {
45
+ base: 'http://localhost:4242',
46
+ undici: mockAgent
47
+ })
48
+
49
+ await instance.ready()
50
+ await instance.close()
51
+ })