@fastify/reply-from 12.2.0 → 12.3.1
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/.claude/settings.local.json +9 -0
- package/README.md +1 -1
- package/index.js +3 -1
- package/lib/request.js +2 -1
- package/package.json +2 -2
- package/test/http2-goaway.test.js +126 -0
- package/test/retry-with-a-custom-handler.test.js +4 -4
- package/test/utils-filter-pseudo-headers.test.js +2 -2
- package/types/index.d.ts +7 -2
- package/types/index.test-d.ts +2 -2
- package/CLAUDE.md +0 -66
package/README.md
CHANGED
|
@@ -320,7 +320,7 @@ Given example
|
|
|
320
320
|
```js
|
|
321
321
|
const customRetryLogic = ({err, req, res, attempt, getDefaultRetry}) => {
|
|
322
322
|
//If this block is not included all non 500 errors will not be retried
|
|
323
|
-
const defaultDelay = getDefaultDelay();
|
|
323
|
+
const defaultDelay = getDefaultDelay(req, res, err, attempt);
|
|
324
324
|
if (defaultDelay) return defaultDelay();
|
|
325
325
|
|
|
326
326
|
//Custom retry logic
|
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 &&
|
|
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)
|
package/lib/request.js
CHANGED
|
@@ -192,7 +192,8 @@ function buildRequest (opts) {
|
|
|
192
192
|
let cancelRequest
|
|
193
193
|
let sessionTimedOut = false
|
|
194
194
|
|
|
195
|
-
if (!http2Client || http2Client.destroyed) {
|
|
195
|
+
if (!http2Client || http2Client.destroyed || http2Client.closed) {
|
|
196
|
+
if (http2Client && !http2Client.destroyed) http2Client.destroy()
|
|
196
197
|
http2Client = http2.connect(baseUrl, http2Opts.sessionOptions)
|
|
197
198
|
http2Client.once('error', done)
|
|
198
199
|
// we might enqueue a large number of requests in this connection
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastify/reply-from",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.3.1",
|
|
4
4
|
"description": "forward your HTTP request to another server, for fastify",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"lint": "eslint",
|
|
10
10
|
"lint:fix": "eslint --fix",
|
|
11
11
|
"test": "npm run test:unit && npm run test:typescript",
|
|
12
|
-
"test:unit": "c8 node --test",
|
|
12
|
+
"test:unit": "c8 node --test --test-timeout=30000",
|
|
13
13
|
"test:typescript": "tsd"
|
|
14
14
|
},
|
|
15
15
|
"repository": {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const h2url = require('h2url')
|
|
4
|
+
const t = require('node:test')
|
|
5
|
+
const Fastify = require('fastify')
|
|
6
|
+
const From = require('..')
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const http2 = require('node:http2')
|
|
10
|
+
|
|
11
|
+
const certs = {
|
|
12
|
+
key: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.key')),
|
|
13
|
+
cert: fs.readFileSync(path.join(__dirname, 'fixtures', 'fastify.cert'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
t.test('http2 goaway handling - reproduces issue #409', async (t) => {
|
|
17
|
+
let requestCount = 0
|
|
18
|
+
|
|
19
|
+
// Create a custom HTTP/2 server that sends GOAWAY after first request
|
|
20
|
+
const targetServer = http2.createServer()
|
|
21
|
+
|
|
22
|
+
let sessionToClose = null
|
|
23
|
+
|
|
24
|
+
targetServer.on('session', (session) => {
|
|
25
|
+
// Store the first session to send GOAWAY later
|
|
26
|
+
if (!sessionToClose) {
|
|
27
|
+
sessionToClose = session
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
targetServer.on('stream', (stream, headers) => {
|
|
32
|
+
requestCount++
|
|
33
|
+
|
|
34
|
+
if (requestCount === 1) {
|
|
35
|
+
// First request: respond normally
|
|
36
|
+
stream.respond({
|
|
37
|
+
':status': 200,
|
|
38
|
+
'content-type': 'application/json'
|
|
39
|
+
})
|
|
40
|
+
stream.end(JSON.stringify({ request: requestCount, message: 'first request' }))
|
|
41
|
+
|
|
42
|
+
// Send GOAWAY after response to close the HTTP/2 session gracefully
|
|
43
|
+
setTimeout(() => {
|
|
44
|
+
if (sessionToClose && !sessionToClose.destroyed) {
|
|
45
|
+
// Send GOAWAY with NO_ERROR to close gracefully
|
|
46
|
+
sessionToClose.goaway(0)
|
|
47
|
+
}
|
|
48
|
+
}, 50)
|
|
49
|
+
} else {
|
|
50
|
+
// Subsequent requests should work with a new session
|
|
51
|
+
stream.respond({
|
|
52
|
+
':status': 200,
|
|
53
|
+
'content-type': 'application/json'
|
|
54
|
+
})
|
|
55
|
+
stream.end(JSON.stringify({ request: requestCount, message: 'subsequent request' }))
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
await new Promise((resolve) => {
|
|
60
|
+
targetServer.listen(0, resolve)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const targetPort = targetServer.address().port
|
|
64
|
+
|
|
65
|
+
// Create proxy server
|
|
66
|
+
const instance = Fastify({
|
|
67
|
+
http2: true,
|
|
68
|
+
https: certs
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
instance.register(From, {
|
|
72
|
+
base: `http://localhost:${targetPort}`,
|
|
73
|
+
http2: true,
|
|
74
|
+
rejectUnauthorized: false
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
instance.get('/', (_request, reply) => {
|
|
78
|
+
reply.from()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
await instance.listen({ port: 0 })
|
|
82
|
+
|
|
83
|
+
const proxyPort = instance.server.address().port
|
|
84
|
+
|
|
85
|
+
// First request - should succeed
|
|
86
|
+
const firstResponse = await h2url.concat({
|
|
87
|
+
url: `https://localhost:${proxyPort}`
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
t.assert.strictEqual(firstResponse.headers[':status'], 200)
|
|
91
|
+
const firstBody = JSON.parse(firstResponse.body)
|
|
92
|
+
t.assert.strictEqual(firstBody.request, 1)
|
|
93
|
+
t.assert.strictEqual(firstBody.message, 'first request')
|
|
94
|
+
|
|
95
|
+
// Wait for GOAWAY to be sent and processed
|
|
96
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
97
|
+
|
|
98
|
+
// Second request - this should fail with current implementation but work with fix
|
|
99
|
+
try {
|
|
100
|
+
const secondResponse = await h2url.concat({
|
|
101
|
+
url: `https://localhost:${proxyPort}`,
|
|
102
|
+
timeout: 1000
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// If we get here with the current code, the request succeeded
|
|
106
|
+
// which means the issue might not be reproduced
|
|
107
|
+
t.assert.strictEqual(secondResponse.headers[':status'], 200)
|
|
108
|
+
const secondBody = JSON.parse(secondResponse.body)
|
|
109
|
+
t.assert.strictEqual(secondBody.request, 2)
|
|
110
|
+
t.assert.strictEqual(secondBody.message, 'subsequent request')
|
|
111
|
+
console.log('Second request succeeded - issue may not be reproduced or fix is already in place')
|
|
112
|
+
} catch (err) {
|
|
113
|
+
// This is expected without the fix - the session is stuck in closed state
|
|
114
|
+
console.log(`Second request failed (expected without fix): ${err.code || err.message}`)
|
|
115
|
+
// This demonstrates the issue exists - session is stuck after GOAWAY
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Cleanup in correct order: clients first, then proxy, then server
|
|
119
|
+
await instance.close()
|
|
120
|
+
targetServer.close()
|
|
121
|
+
|
|
122
|
+
// Force exit after a short delay to ensure test completes
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
process.exit(0)
|
|
125
|
+
}, 100)
|
|
126
|
+
})
|
|
@@ -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('
|
|
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.
|
|
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: (
|
|
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) =>
|
|
72
|
+
retryDelay?: (details: RetryDetails) => number | null;
|
|
68
73
|
retriesCount?: number;
|
|
69
74
|
onResponse?: (
|
|
70
75
|
request: FastifyRequest<RequestGenericInterface, RawServerBase>,
|
package/types/index.test-d.ts
CHANGED
|
@@ -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') {
|
package/CLAUDE.md
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
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.
|