Skip to content

Commit 998a593

Browse files
authored
feat: allow connection header in request (nodejs#1829)
1 parent d13781b commit 998a593

File tree

9 files changed

+194
-8
lines changed

9 files changed

+194
-8
lines changed

docs/api/Dispatcher.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo
192192
* **origin** `string | URL`
193193
* **path** `string`
194194
* **method** `string`
195+
* **reset** `boolean` (optional) - Default: `false` - Indicates whether the request should attempt to create a long-living connection by sending the `connection: keep-alive` header, or close it immediately after response by sending `connection: close`.
195196
* **body** `string | Buffer | Uint8Array | stream.Readable | Iterable | AsyncIterable | null` (optional) - Default: `null`
196197
* **headers** `UndiciHeaders | string[]` (optional) - Default: `null`.
197198
* **query** `Record<string, any> | null` (optional) - Default: `null` - Query string params to be embedded in the request URL. Note that both keys and values of query are encoded using `encodeURIComponent`. If for some reason you need to send them unencoded, embed query params into path directly instead.

lib/client.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,7 +1295,7 @@ function _resume (client, sync) {
12951295
}
12961296

12971297
function write (client, request) {
1298-
const { body, method, path, host, upgrade, headers, blocking } = request
1298+
const { body, method, path, host, upgrade, headers, blocking, reset } = request
12991299

13001300
// https://tools.ietf.org/html/rfc7231#section-4.3.1
13011301
// https://tools.ietf.org/html/rfc7231#section-4.3.2
@@ -1363,7 +1363,6 @@ function write (client, request) {
13631363

13641364
if (method === 'HEAD') {
13651365
// https://github.com/mcollina/undici/issues/258
1366-
13671366
// Close after a HEAD request to interop with misbehaving servers
13681367
// that may send a body in the response.
13691368

@@ -1377,6 +1376,10 @@ function write (client, request) {
13771376
socket[kReset] = true
13781377
}
13791378

1379+
if (reset) {
1380+
socket[kReset] = true
1381+
}
1382+
13801383
if (client[kMaxRequests] && socket[kCounter]++ >= client[kMaxRequests]) {
13811384
socket[kReset] = true
13821385
}

lib/core/request.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ class Request {
6565
upgrade,
6666
headersTimeout,
6767
bodyTimeout,
68+
reset,
6869
throwOnError
6970
}, handler) {
7071
if (typeof path !== 'string') {
@@ -97,6 +98,10 @@ class Request {
9798
throw new InvalidArgumentError('invalid bodyTimeout')
9899
}
99100

101+
if (reset != null && typeof reset !== 'boolean') {
102+
throw new InvalidArgumentError('invalid reset')
103+
}
104+
100105
this.headersTimeout = headersTimeout
101106

102107
this.bodyTimeout = bodyTimeout
@@ -139,6 +144,8 @@ class Request {
139144

140145
this.blocking = blocking == null ? false : blocking
141146

147+
this.reset = reset == null ? false : reset
148+
142149
this.host = null
143150

144151
this.contentLength = null
@@ -320,7 +327,12 @@ function processHeader (request, key, val) {
320327
key.length === 10 &&
321328
key.toLowerCase() === 'connection'
322329
) {
323-
throw new InvalidArgumentError('invalid connection header')
330+
const value = val.toLowerCase()
331+
if (value !== 'close' && value !== 'keep-alive') {
332+
throw new InvalidArgumentError('invalid connection header')
333+
} else if (value === 'close') {
334+
request.reset = true
335+
}
324336
} else if (
325337
key.length === 10 &&
326338
key.toLowerCase() === 'keep-alive'

test/client.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,77 @@ test('basic get', (t) => {
8888
})
8989
})
9090

91+
test('basic get with custom request.reset=true', (t) => {
92+
t.plan(26)
93+
94+
const server = createServer((req, res) => {
95+
t.equal('/', req.url)
96+
t.equal('GET', req.method)
97+
t.equal(`localhost:${server.address().port}`, req.headers.host)
98+
t.equal(req.headers.connection, 'close')
99+
t.equal(undefined, req.headers.foo)
100+
t.equal('bar', req.headers.bar)
101+
t.equal(undefined, req.headers['content-length'])
102+
res.setHeader('Content-Type', 'text/plain')
103+
res.end('hello')
104+
})
105+
t.teardown(server.close.bind(server))
106+
107+
const reqHeaders = {
108+
foo: undefined,
109+
bar: 'bar'
110+
}
111+
112+
server.listen(0, () => {
113+
const client = new Client(`http://localhost:${server.address().port}`, {})
114+
t.teardown(client.close.bind(client))
115+
116+
t.equal(client[kUrl].origin, `http://localhost:${server.address().port}`)
117+
118+
const signal = new EE()
119+
client.request({
120+
signal,
121+
path: '/',
122+
method: 'GET',
123+
reset: true,
124+
headers: reqHeaders
125+
}, (err, data) => {
126+
t.error(err)
127+
const { statusCode, headers, body } = data
128+
t.equal(statusCode, 200)
129+
t.equal(signal.listenerCount('abort'), 1)
130+
t.equal(headers['content-type'], 'text/plain')
131+
const bufs = []
132+
body.on('data', (buf) => {
133+
bufs.push(buf)
134+
})
135+
body.on('end', () => {
136+
t.equal(signal.listenerCount('abort'), 0)
137+
t.equal('hello', Buffer.concat(bufs).toString('utf8'))
138+
})
139+
})
140+
t.equal(signal.listenerCount('abort'), 1)
141+
142+
client.request({
143+
path: '/',
144+
reset: true,
145+
method: 'GET',
146+
headers: reqHeaders
147+
}, (err, { statusCode, headers, body }) => {
148+
t.error(err)
149+
t.equal(statusCode, 200)
150+
t.equal(headers['content-type'], 'text/plain')
151+
const bufs = []
152+
body.on('data', (buf) => {
153+
bufs.push(buf)
154+
})
155+
body.on('end', () => {
156+
t.equal('hello', Buffer.concat(bufs).toString('utf8'))
157+
})
158+
})
159+
})
160+
})
161+
91162
test('basic get with query params', (t) => {
92163
t.plan(4)
93164

test/invalid-headers.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ test('invalid headers', (t) => {
5050
path: '/',
5151
method: 'GET',
5252
headers: {
53-
connection: 'close'
53+
connection: 'asd'
5454
}
5555
}, (err, data) => {
5656
t.type(err, errors.InvalidArgumentError)

test/request.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,3 +149,100 @@ test('Absolute URL as pathname should be included in req.path', async (t) => {
149149
t.equal(noSlashPath2Arg.statusCode, 200)
150150
t.end()
151151
})
152+
153+
test('DispatchOptions#reset', scope => {
154+
scope.plan(4)
155+
156+
scope.test('Should throw if invalid reset option', t => {
157+
t.plan(1)
158+
159+
t.rejects(request({
160+
method: 'GET',
161+
origin: 'http://somehost.xyz',
162+
reset: 0
163+
}), 'invalid reset')
164+
})
165+
166+
scope.test('Should include "connection:close" if reset true', async t => {
167+
const server = createServer((req, res) => {
168+
t.equal('GET', req.method)
169+
t.equal(`localhost:${server.address().port}`, req.headers.host)
170+
t.equal(req.headers.connection, 'close')
171+
res.statusCode = 200
172+
res.end('hello')
173+
})
174+
175+
t.plan(3)
176+
177+
t.teardown(server.close.bind(server))
178+
179+
await new Promise((resolve, reject) => {
180+
server.listen(0, (err) => {
181+
if (err != null) reject(err)
182+
else resolve()
183+
})
184+
})
185+
186+
await request({
187+
method: 'GET',
188+
origin: `http://localhost:${server.address().port}`,
189+
reset: true
190+
})
191+
})
192+
193+
scope.test('Should include "connection:keep-alive" if reset false', async t => {
194+
const server = createServer((req, res) => {
195+
t.equal('GET', req.method)
196+
t.equal(`localhost:${server.address().port}`, req.headers.host)
197+
t.equal(req.headers.connection, 'keep-alive')
198+
res.statusCode = 200
199+
res.end('hello')
200+
})
201+
202+
t.plan(3)
203+
204+
t.teardown(server.close.bind(server))
205+
206+
await new Promise((resolve, reject) => {
207+
server.listen(0, (err) => {
208+
if (err != null) reject(err)
209+
else resolve()
210+
})
211+
})
212+
213+
await request({
214+
method: 'GET',
215+
origin: `http://localhost:${server.address().port}`,
216+
reset: false
217+
})
218+
})
219+
220+
scope.test('Should react to manual set of "connection:close" header', async t => {
221+
const server = createServer((req, res) => {
222+
t.equal('GET', req.method)
223+
t.equal(`localhost:${server.address().port}`, req.headers.host)
224+
t.equal(req.headers.connection, 'close')
225+
res.statusCode = 200
226+
res.end('hello')
227+
})
228+
229+
t.plan(3)
230+
231+
t.teardown(server.close.bind(server))
232+
233+
await new Promise((resolve, reject) => {
234+
server.listen(0, (err) => {
235+
if (err != null) reject(err)
236+
else resolve()
237+
})
238+
})
239+
240+
await request({
241+
method: 'GET',
242+
origin: `http://localhost:${server.address().port}`,
243+
headers: {
244+
connection: 'close'
245+
}
246+
})
247+
})
248+
})

test/types/api.test-d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { Dispatcher, request, stream, pipeline, connect, upgrade } from '../..'
55
// request
66
expectAssignable<Promise<Dispatcher.ResponseData>>(request(''))
77
expectAssignable<Promise<Dispatcher.ResponseData>>(request('', { }))
8-
expectAssignable<Promise<Dispatcher.ResponseData>>(request('', { method: 'GET' }))
8+
expectAssignable<Promise<Dispatcher.ResponseData>>(request('', { method: 'GET', reset: false }))
99

1010
// stream
1111
expectAssignable<Promise<Dispatcher.StreamData>>(stream('', { method: 'GET' }, data => {

test/types/dispatcher.test-d.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ expectAssignable<Dispatcher>(new Dispatcher())
1414
expectAssignable<boolean>(dispatcher.dispatch({ origin: '', path: '', method: 'GET' }, {}))
1515
expectAssignable<boolean>(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: [] }, {}))
1616
expectAssignable<boolean>(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: {} }, {}))
17-
expectAssignable<boolean>(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: null }, {}))
17+
expectAssignable<boolean>(dispatcher.dispatch({ origin: '', path: '', method: 'GET', headers: null, reset: true }, {}))
1818
expectAssignable<boolean>(dispatcher.dispatch({ origin: new URL('http://localhost'), path: '', method: 'GET' }, {}))
1919

2020
// connect
@@ -30,7 +30,7 @@ expectAssignable<Dispatcher>(new Dispatcher())
3030
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, query: { pageNum: 1, id: 'abc' } }))
3131
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, throwOnError: true }))
3232
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }))
33-
expectAssignable<void>(dispatcher.request({ origin: '', path: '', method: 'GET' }, (err, data) => {
33+
expectAssignable<void>(dispatcher.request({ origin: '', path: '', method: 'GET', reset: true }, (err, data) => {
3434
expectAssignable<Error | null>(err)
3535
expectAssignable<Dispatcher.ResponseData>(data)
3636
}))
@@ -59,7 +59,7 @@ expectAssignable<Dispatcher>(new Dispatcher())
5959
return new Writable()
6060
}))
6161
expectAssignable<void>(dispatcher.stream(
62-
{ origin: '', path: '', method: 'GET' },
62+
{ origin: '', path: '', method: 'GET', reset: false },
6363
data => {
6464
expectAssignable<Dispatcher.StreamFactoryData>(data)
6565
return new Writable()

types/dispatcher.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ declare namespace Dispatcher {
111111
headersTimeout?: number | null;
112112
/** The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use 0 to disable it entirely. Defaults to 30 seconds. */
113113
bodyTimeout?: number | null;
114+
/** Whether the request should stablish a keep-alive or not. Default `false` */
115+
reset?: boolean;
114116
/** Whether Undici should throw an error upon receiving a 4xx or 5xx response from the server. Defaults to false */
115117
throwOnError?: boolean;
116118
}

0 commit comments

Comments
 (0)