@fastify/reply-from 12.3.0 → 12.4.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.
@@ -14,6 +14,11 @@ on:
14
14
  - 'docs/**'
15
15
  - '*.md'
16
16
 
17
+ # This allows a subsequently queued workflow run to interrupt previous runs
18
+ concurrency:
19
+ group: "${{ github.workflow }}-${{ github.event.pull_request.head.label || github.head_ref || github.ref }}"
20
+ cancel-in-progress: true
21
+
17
22
  permissions:
18
23
  contents: read
19
24
 
package/CLAUDE.md ADDED
@@ -0,0 +1,66 @@
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Development Commands
6
+
7
+ ### Testing
8
+ - `npm test` - Run all tests (unit + TypeScript)
9
+ - `npm run test:unit` - Run unit tests with coverage using c8 and Node.js test runner
10
+ - `npm run test:typescript` - Run TypeScript definition tests using tsd
11
+
12
+ ### Linting
13
+ - `npm run lint` - Run ESLint to check code style
14
+ - `npm run lint:fix` - Run ESLint with automatic fixes
15
+ - ESLint configuration uses neostandard with TypeScript support
16
+
17
+ ### Building
18
+ - No build step required - this is a CommonJS library
19
+
20
+ ## Architecture Overview
21
+
22
+ This is a Fastify plugin for proxying HTTP requests to upstream servers. The plugin decorates the Fastify reply with a `from()` method.
23
+
24
+ ### Key Components
25
+
26
+ **Main Plugin (`index.js`)**
27
+ - Entry point that registers the plugin using fastify-plugin
28
+ - Decorates `reply.from(source, opts)` method
29
+ - Handles request proxying, retries, caching, and error handling
30
+ - Supports HTTP/1.1, HTTP/2, and Undici client strategies
31
+
32
+ **Request Builder (`lib/request.js`)**
33
+ - Factory for creating request handlers based on transport (HTTP/1.1, HTTP/2, Undici)
34
+ - Handles different agent configurations (HTTP, HTTPS, Unix sockets)
35
+ - Manages timeouts, connection pooling, and retry logic
36
+ - Returns unified interface: `{ request, close, retryOnError }`
37
+
38
+ **Utilities (`lib/utils.js`)**
39
+ - `filterPseudoHeaders()` - Removes HTTP/2 pseudo-headers (starting with :)
40
+ - `copyHeaders()` - Copies headers to Fastify reply
41
+ - `stripHttp1ConnectionHeaders()` - Removes HTTP/1 connection-specific headers for HTTP/2
42
+ - `buildURL()` - Constructs destination URLs with base path validation
43
+
44
+ **Error Definitions (`lib/errors.js`)**
45
+ - Standardized error types using @fastify/error
46
+ - Maps different error conditions to appropriate HTTP status codes
47
+ - Includes timeout, connection reset, and gateway errors
48
+
49
+ ### Transport Strategies
50
+
51
+ 1. **Undici (default)** - High-performance HTTP/1.1 client with connection pooling
52
+ 2. **HTTP/2** - Native Node.js HTTP/2 client for modern protocols
53
+ 3. **HTTP/1.1** - Node.js built-in http/https modules with custom agents
54
+
55
+ ### Key Features
56
+
57
+ - **Caching**: URL parsing cache using toad-cache LruMap (default 100 entries)
58
+ - **Retries**: Configurable retry logic for socket hangups and 503 errors
59
+ - **Headers**: Smart header rewriting for HTTP/1.1 ↔ HTTP/2 compatibility
60
+ - **Body handling**: Supports streams, JSON encoding, and custom content types
61
+ - **Unix sockets**: Special handling for unix+http: and unix+https: protocols
62
+ - **Timeouts**: Per-request and session-level timeout configuration
63
+
64
+ ### Configuration
65
+
66
+ The plugin accepts extensive configuration for different transport methods, retry behavior, caching, and request/response transformation hooks. See README.md for full option documentation.
package/README.md CHANGED
@@ -13,9 +13,6 @@ HTTP2 to HTTP is supported too.
13
13
  npm i @fastify/reply-from
14
14
  ```
15
15
 
16
- ## Compatibility with @fastify/multipart
17
- `@fastify/reply-from` and [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) should not be registered as sibling plugins nor should they be registered in plugins that have a parent-child relationship.`<br>` The two plugins are incompatible, in the sense that the behavior of `@fastify/reply-from` might not be the expected one when the above-mentioned conditions are not respected.`<br>` This is due to the fact that `@fastify/multipart` consumes the multipart content by parsing it, hence this content is not forwarded to the target service by `@fastify/reply-from`.`<br>`
18
- 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.
19
16
 
20
17
  ## Usage
21
18
 
@@ -320,7 +317,7 @@ Given example
320
317
  ```js
321
318
  const customRetryLogic = ({err, req, res, attempt, getDefaultRetry}) => {
322
319
  //If this block is not included all non 500 errors will not be retried
323
- const defaultDelay = getDefaultDelay();
320
+ const defaultDelay = getDefaultDelay(req, res, err, attempt);
324
321
  if (defaultDelay) return defaultDelay();
325
322
 
326
323
  //Custom retry logic
@@ -518,14 +515,35 @@ This library has:
518
515
  When a timeout happens, [`504 Gateway Timeout`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/504)
519
516
  will be returned to the client.
520
517
 
521
- ## TODO
518
+ ## Compatibility with @fastify/multipart
519
+
520
+ `@fastify/reply-from` and [`@fastify/multipart`](https://github.com/fastify/fastify-multipart) should not be registered as sibling plugins nor should they be registered in plugins that have a parent-child relationship.
521
+
522
+ The two plugins are incompatible, in the sense that the behavior of `@fastify/reply-from` might not be the expected one when the above-mentioned conditions are not respected.
523
+ This is due to the fact that `@fastify/multipart` consumes the multipart content by parsing it, hence this content is not forwarded to the target service by `@fastify/reply-from`.
524
+
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
+
527
+ ### Proxying multipart/form-data without @fastify/multipart
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`:
530
+
531
+ ```js
532
+ // Register a custom content type parser for multipart/form-data
533
+ // This passes the raw body through without parsing
534
+ fastify.addContentTypeParser('multipart/form-data', function (req, body, done) {
535
+ done(null, body)
536
+ })
537
+
538
+ fastify.register(require('@fastify/reply-from'))
539
+
540
+ fastify.post('/upload', (request, reply) => {
541
+ // The multipart data will be proxied as-is to the upstream server
542
+ reply.from('http://upstream-server.com/upload')
543
+ })
544
+ ```
522
545
 
523
- * [ ] support overriding the body with a stream
524
- * [ ] forward the request id to the other peer might require some
525
- refactoring because we have to make the `req.id` unique
526
- (see [hyperid](https://npm.im/hyperid)).
527
- * [ ] Support origin HTTP2 push
528
- * [X] benchmarks
546
+ This approach allows `multipart/form-data` to be proxied correctly while avoiding the incompatibility with `@fastify/multipart`.
529
547
 
530
548
  ## License
531
549
 
package/index.js CHANGED
@@ -47,6 +47,8 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
47
47
  globalAgent: opts.globalAgent,
48
48
  destroyAgent: opts.destroyAgent
49
49
  })
50
+
51
+ const isHttp2 = !!opts.http2
50
52
  if (requestBuilt instanceof Error) {
51
53
  next(requestBuilt)
52
54
  return
@@ -209,7 +211,7 @@ const fastifyReplyFrom = fp(function from (fastify, opts, next) {
209
211
  onError(this, { error: new BadGatewayError() })
210
212
  this.request.log.warn(err, 'response has invalid status code')
211
213
  }
212
- if (this.request.raw.aborted && res.stream) {
214
+ if (this.request.raw.aborted && isHttp2) {
213
215
  // the request could have been canceled before we got a response from the target
214
216
  // forward this to the upstream server and close the stream to prevent leaks
215
217
  res.stream.close(NGHTTP2_CANCEL)
@@ -283,7 +285,7 @@ function onErrorDefault (reply, { error }) {
283
285
  }
284
286
 
285
287
  function isFastifyMultipartRegistered (fastify) {
286
- return fastify.hasContentTypeParser('multipart/form-data')
288
+ return fastify.hasPlugin('@fastify/multipart')
287
289
  }
288
290
 
289
291
  function createRequestRetry (requestImpl, reply, retryHandler) {
package/lib/utils.js CHANGED
@@ -42,12 +42,17 @@ function stripHttp1ConnectionHeaders (headers) {
42
42
  case 'connection':
43
43
  case 'upgrade':
44
44
  case 'http2-settings':
45
- case 'te':
46
45
  case 'transfer-encoding':
47
46
  case 'proxy-connection':
48
47
  case 'keep-alive':
49
48
  case 'host':
50
49
  break
50
+ case 'te':
51
+ // see illegal connection specific header handling in Node.js
52
+ if (headers['te'] === 'trailers') {
53
+ dest[header] = headers[header]
54
+ }
55
+ break
51
56
  default:
52
57
  dest[header] = headers[header]
53
58
  break
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastify/reply-from",
3
- "version": "12.3.0",
3
+ "version": "12.4.0",
4
4
  "description": "forward your HTTP request to another server, for fastify",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -60,7 +60,6 @@
60
60
  "devDependencies": {
61
61
  "@fastify/formbody": "^8.0.0",
62
62
  "@fastify/multipart": "^9.0.0",
63
- "@fastify/pre-commit": "^2.1.0",
64
63
  "@sinonjs/fake-timers": "^14.0.0",
65
64
  "@types/node": "^24.0.8",
66
65
  "@types/tap": "^18.0.0",
@@ -74,7 +73,7 @@
74
73
  "proxy": "^2.1.1",
75
74
  "proxyquire": "^2.1.3",
76
75
  "split2": "^4.2.0",
77
- "tsd": "^0.32.0"
76
+ "tsd": "^0.33.0"
78
77
  },
79
78
  "dependencies": {
80
79
  "@fastify/error": "^4.0.0",
@@ -85,10 +84,6 @@
85
84
  "toad-cache": "^3.7.0",
86
85
  "undici": "^7.0.0"
87
86
  },
88
- "pre-commit": [
89
- "lint",
90
- "test"
91
- ],
92
87
  "publishConfig": {
93
88
  "access": "public"
94
89
  }
@@ -0,0 +1,60 @@
1
+ 'use strict'
2
+ const h2url = require('h2url')
3
+ const t = require('node:test')
4
+ const Fastify = require('fastify')
5
+ const From = require('..')
6
+ const fs = require('node:fs')
7
+ const path = require('node:path')
8
+ const certs = {
9
+ key: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.key')),
10
+ cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.cert'))
11
+ }
12
+
13
+ t.test('do not strip te header if set to trailing', async (t) => {
14
+ const instance = Fastify({
15
+ http2: true,
16
+ https: certs
17
+ })
18
+
19
+ t.after(() => instance.close())
20
+
21
+ const target = Fastify({
22
+ http2: true
23
+ })
24
+
25
+ target.get('/', (request, reply) => {
26
+ t.assert.strictEqual(request.headers['te'], 'trailers')
27
+
28
+ reply.send({
29
+ hello: 'world'
30
+ })
31
+ })
32
+
33
+ instance.get('/', (_request, reply) => {
34
+ reply.from()
35
+ })
36
+
37
+ t.after(() => target.close())
38
+
39
+ await target.listen({ port: 0 })
40
+
41
+ instance.register(From, {
42
+ base: `http://localhost:${target.server.address().port}`,
43
+ http2: true,
44
+ rejectUnauthorized: false
45
+ })
46
+
47
+ await instance.listen({ port: 0 })
48
+
49
+ const { headers } = await h2url.concat({
50
+ url: `https://localhost:${instance.server.address().port}`,
51
+ headers: {
52
+ te: 'trailers'
53
+ }
54
+ })
55
+
56
+ t.assert.strictEqual(headers[':status'], 200)
57
+
58
+ instance.close()
59
+ target.close()
60
+ })
@@ -0,0 +1,74 @@
1
+ 'use strict'
2
+
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const t = require('node:test')
6
+ const Fastify = require('fastify')
7
+ const { request } = require('undici')
8
+ const From = require('..')
9
+ const http = require('node:http')
10
+ const FormData = require('form-data')
11
+
12
+ t.test('multipart/form-data proxying with custom content type parser', async (t) => {
13
+ t.plan(7)
14
+
15
+ const filetPath = path.join(__dirname, 'fixtures', 'file.txt')
16
+
17
+ // Target server that expects multipart/form-data
18
+ const target = http.createServer((req, res) => {
19
+ t.assert.ok('request proxied')
20
+ t.assert.strictEqual(req.method, 'POST')
21
+ t.assert.match(req.headers['content-type'], /^multipart\/form-data/)
22
+
23
+ let data = ''
24
+ req.setEncoding('utf8')
25
+ req.on('data', (chunk) => {
26
+ data += chunk
27
+ })
28
+ req.on('end', () => {
29
+ // Verify the multipart data contains our form fields
30
+ t.assert.match(data, /Content-Disposition: form-data; name="key"/)
31
+ t.assert.match(data, /value/)
32
+ t.assert.match(data, /Content-Disposition: form-data; name="file"/)
33
+
34
+ res.setHeader('content-type', 'application/json')
35
+ res.statusCode = 200
36
+ res.end(JSON.stringify({ received: 'multipart data' }))
37
+ })
38
+ })
39
+
40
+ // Fastify instance with custom multipart parser (not @fastify/multipart)
41
+ const fastify = Fastify()
42
+
43
+ // Register custom content type parser for multipart/form-data
44
+ // This allows the raw body to be passed through without parsing
45
+ fastify.addContentTypeParser('multipart/form-data', function (req, body, done) {
46
+ done(null, body)
47
+ })
48
+
49
+ fastify.register(From)
50
+
51
+ fastify.post('/', (request, reply) => {
52
+ reply.from(`http://localhost:${target.address().port}`)
53
+ })
54
+
55
+ t.after(() => fastify.close())
56
+ t.after(() => target.close())
57
+
58
+ await new Promise(resolve => fastify.listen({ port: 0 }, resolve))
59
+ await new Promise(resolve => target.listen({ port: 0 }, resolve))
60
+
61
+ // Create multipart form data
62
+ const form = new FormData()
63
+ form.append('key', 'value')
64
+ form.append('file', fs.createReadStream(filetPath, { encoding: 'utf-8' }))
65
+
66
+ // Send request with multipart data
67
+ const result = await request(`http://localhost:${fastify.server.address().port}`, {
68
+ method: 'POST',
69
+ headers: form.getHeaders(),
70
+ body: form
71
+ })
72
+
73
+ t.assert.deepStrictEqual(await result.body.json(), { received: 'multipart data' })
74
+ })
@@ -55,8 +55,8 @@ test('a 500 status code with no custom handler should fail', async (t) => {
55
55
  })
56
56
 
57
57
  test("a server 500's with a custom handler and should revive", async (t) => {
58
- const customRetryLogic = ({ req, res, getDefaultDelay }) => {
59
- const defaultDelay = getDefaultDelay()
58
+ const customRetryLogic = ({ req, res, err, attempt, getDefaultDelay }) => {
59
+ const defaultDelay = getDefaultDelay(req, res, err, attempt)
60
60
  if (defaultDelay) return defaultDelay
61
61
 
62
62
  if (res && res.statusCode === 500 && req.method === 'GET') {
@@ -92,9 +92,9 @@ test('custom retry does not invoke the default delay causing a 501', async (t) =
92
92
  })
93
93
 
94
94
  test('custom retry delay functions can invoke the default delay', async (t) => {
95
- const customRetryLogic = ({ req, res, getDefaultDelay }) => {
95
+ const customRetryLogic = ({ req, res, err, attempt, getDefaultDelay }) => {
96
96
  // registering the default retry logic for non 500 errors if it occurs
97
- const defaultDelay = getDefaultDelay()
97
+ const defaultDelay = getDefaultDelay(req, res, err, attempt)
98
98
  if (defaultDelay) return defaultDelay
99
99
 
100
100
  if (res && res.statusCode === 500 && req.method === 'GET') {
@@ -1,6 +1,6 @@
1
1
  'use strict'
2
2
 
3
- const test = require('tap').test
3
+ const test = require('node:test')
4
4
  const filterPseudoHeaders = require('../lib/utils').filterPseudoHeaders
5
5
 
6
6
  test('filterPseudoHeaders', t => {
@@ -11,7 +11,7 @@ test('filterPseudoHeaders', t => {
11
11
  ':method': 'GET'
12
12
  }
13
13
 
14
- t.strictSame(filterPseudoHeaders(headers), {
14
+ t.assert.deepStrictEqual(filterPseudoHeaders(headers), {
15
15
  accept: '*/*',
16
16
  'content-type': 'text/html; charset=UTF-8'
17
17
  })
package/types/index.d.ts CHANGED
@@ -54,7 +54,12 @@ declare namespace fastifyReplyFrom {
54
54
  res: FastifyReply<RouteGenericInterface, RawServerBase>;
55
55
  attempt: number;
56
56
  retriesCount: number;
57
- getDefaultDelay: () => number | null;
57
+ getDefaultDelay: (
58
+ req: FastifyRequest<RequestGenericInterface, RawServerBase>,
59
+ res: FastifyReply<RouteGenericInterface, RawServerBase>,
60
+ err: Error,
61
+ retries: number,
62
+ ) => number | null;
58
63
  }
59
64
 
60
65
  export type RawServerResponse<T extends RawServerBase> = RawReplyDefaultExpression<T> & {
@@ -64,7 +69,7 @@ declare namespace fastifyReplyFrom {
64
69
  export interface FastifyReplyFromHooks {
65
70
  queryString?: { [key: string]: unknown } | QueryStringFunction;
66
71
  contentType?: string;
67
- retryDelay?: (details: RetryDetails) => {} | null;
72
+ retryDelay?: (details: RetryDetails) => number | null;
68
73
  retriesCount?: number;
69
74
  onResponse?: (
70
75
  request: FastifyRequest<RequestGenericInterface, RawServerBase>,
@@ -103,8 +103,8 @@ async function main () {
103
103
  instance.get('/http2', (_request, reply) => {
104
104
  reply.from('/', {
105
105
  method: 'POST',
106
- retryDelay: ({ req, res, getDefaultDelay }) => {
107
- const defaultDelay = getDefaultDelay()
106
+ retryDelay: ({ req, res, err, attempt, getDefaultDelay }) => {
107
+ const defaultDelay = getDefaultDelay(req, res, err, attempt)
108
108
  if (defaultDelay) return defaultDelay
109
109
 
110
110
  if (res && res.statusCode === 500 && req.method === 'GET') {
@@ -1,9 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(npm run lint:*)",
5
- "Bash(node:*)"
6
- ],
7
- "deny": []
8
- }
9
- }