@nxtedition/nxt-undici 1.0.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.
@@ -0,0 +1,79 @@
1
+ const { parseHeaders } = require('../utils')
2
+ const xuid = require('xuid')
3
+ const { performance } = require('perf_hooks')
4
+
5
+ class Handler {
6
+ constructor(opts, { handler }) {
7
+ this.handler = handler
8
+ this.opts = opts.id ? opts : { ...opts, id: xuid() }
9
+ this.abort = null
10
+ this.aborted = false
11
+ this.logger = opts.logger.child({ ureq: { id: opts.id } })
12
+ this.pos = 0
13
+ this.startTime = 0
14
+ }
15
+
16
+ onConnect(abort) {
17
+ this.abort = abort
18
+ this.startTime = performance.now()
19
+ this.logger.debug({ ureq: this.opts }, 'upstream request started')
20
+ this.handler.onConnect((reason) => {
21
+ this.aborted = true
22
+ this.abort(reason)
23
+ })
24
+ }
25
+
26
+ onUpgrade(statusCode, rawHeaders, socket) {
27
+ this.logger.debug({ ureq: this.opts }, 'upstream request upgraded')
28
+ socket.on('close', () => {
29
+ this.logger.debug({ ureq: this.opts }, 'upstream request socket closed')
30
+ })
31
+ return this.handler.onUpgrade(statusCode, rawHeaders, socket)
32
+ }
33
+
34
+ onBodySent(chunk) {
35
+ return this.handler.onBodySent(chunk)
36
+ }
37
+
38
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
39
+ this.logger.debug(
40
+ {
41
+ ures: { statusCode, headers: parseHeaders(rawHeaders) },
42
+ elapsedTime: this.startTime - performance.now(),
43
+ },
44
+ 'upstream request response',
45
+ )
46
+ return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
47
+ }
48
+
49
+ onData(chunk) {
50
+ this.pos += chunk.length
51
+ return this.handler.onData(chunk)
52
+ }
53
+
54
+ onComplete(rawTrailers) {
55
+ this.logger.debug(
56
+ { bytesRead: this.pos, elapsedTime: this.startTime - performance.now() },
57
+ 'upstream request completed',
58
+ )
59
+ return this.handler.onComplete(rawTrailers)
60
+ }
61
+
62
+ onError(err) {
63
+ if (this.aborted) {
64
+ this.logger.debug(
65
+ { bytesRead: this.pos, elapsedTime: this.startTime - performance.now(), err },
66
+ 'upstream request aborted',
67
+ )
68
+ } else {
69
+ this.logger.error(
70
+ { bytesRead: this.pos, elapsedTime: this.startTime - performance.now(), err },
71
+ 'upstream request failed',
72
+ )
73
+ }
74
+ return this.handler.onError(err)
75
+ }
76
+ }
77
+
78
+ module.exports = (dispatch) => (opts, handler) =>
79
+ opts.logger ? dispatch(opts, new Handler(opts, { handler })) : dispatch(opts, handler)
@@ -0,0 +1,200 @@
1
+ const createError = require('http-errors')
2
+ const net = require('net')
3
+
4
+ class Handler {
5
+ constructor(opts, { handler }) {
6
+ this.handler = handler
7
+ this.opts = opts
8
+ }
9
+
10
+ onConnect(abort) {
11
+ return this.handler.onConnect(abort)
12
+ }
13
+
14
+ onUpgrade(statusCode, rawHeaders, socket) {
15
+ return this.handler.onUpgrade(
16
+ statusCode,
17
+ reduceHeaders(
18
+ {
19
+ headers: rawHeaders,
20
+ httpVersion: this.opts.proxy.httpVersion ?? this.opts.proxy.req?.httpVersion,
21
+ socket: null,
22
+ proxyName: this.opts.proxy.name,
23
+ },
24
+ (acc, key, val) => {
25
+ acc.push(key, val)
26
+ return acc
27
+ },
28
+ [],
29
+ ),
30
+ socket,
31
+ )
32
+ }
33
+
34
+ onBodySent(chunk) {
35
+ return this.handler.onBodySent(chunk)
36
+ }
37
+
38
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
39
+ return this.handler.onHeaders(
40
+ statusCode,
41
+ reduceHeaders(
42
+ {
43
+ headers: rawHeaders,
44
+ httpVersion: this.opts.proxy.httpVersion ?? this.opts.proxy.req?.httpVersion,
45
+ socket: null,
46
+ proxyName: this.opts.proxy.name,
47
+ },
48
+ (acc, key, val) => {
49
+ acc.push(key, val)
50
+ return acc
51
+ },
52
+ [],
53
+ ),
54
+ resume,
55
+ statusMessage,
56
+ )
57
+ }
58
+
59
+ onData(chunk) {
60
+ return this.handler.onData(chunk)
61
+ }
62
+
63
+ onComplete(rawTrailers) {
64
+ return this.handler.onComplete(rawTrailers)
65
+ }
66
+
67
+ onError(err) {
68
+ return this.handler.onError(err)
69
+ }
70
+ }
71
+
72
+ module.exports = (dispatch) => (opts, handler) => {
73
+ if (!opts.proxy) {
74
+ return dispatch(opts, handler)
75
+ }
76
+
77
+ const headers = reduceHeaders(
78
+ {
79
+ headers: opts.headers ?? {},
80
+ httpVersion: opts.proxy.httpVersion ?? opts.proxy.req?.httpVersion,
81
+ socket: opts.proxy.socket ?? opts.proxy.req?.socket,
82
+ proxyName: opts.proxy.name,
83
+ },
84
+ (obj, key, val) => {
85
+ obj[key] = val
86
+ return obj
87
+ },
88
+ {},
89
+ )
90
+
91
+ opts = { ...opts, headers }
92
+
93
+ return dispatch(opts, new Handler(opts, { handler }))
94
+ }
95
+
96
+ // This expression matches hop-by-hop headers.
97
+ // These headers are meaningful only for a single transport-level connection,
98
+ // and must not be retransmitted by proxies or cached.
99
+ const HOP_EXPR =
100
+ /^(te|host|upgrade|trailers|connection|keep-alive|http2-settings|transfer-encoding|proxy-connection|proxy-authenticate|proxy-authorization)$/i
101
+
102
+ function forEachHeader(headers, fn) {
103
+ if (Array.isArray(headers)) {
104
+ for (let n = 0; n < headers.length; n += 2) {
105
+ fn(headers[n + 0].toString(), headers[n + 1].toString())
106
+ }
107
+ } else {
108
+ for (const [key, val] of Object.entries(headers)) {
109
+ fn(key, val)
110
+ }
111
+ }
112
+ }
113
+
114
+ // Removes hop-by-hop and pseudo headers.
115
+ // Updates via and forwarded headers.
116
+ // Only hop-by-hop headers may be set using the Connection general header.
117
+ function reduceHeaders({ headers, proxyName, httpVersion, socket }, fn, acc) {
118
+ let via = ''
119
+ let forwarded = ''
120
+ let host = ''
121
+ let authority = ''
122
+ let connection = ''
123
+
124
+ forEachHeader(headers, (key, val) => {
125
+ const len = key.length
126
+ if (len === 3 && !via && key.toLowerCase() === 'via') {
127
+ via = val
128
+ } else if (len === 4 && !host && key.toLowerCase() === 'host') {
129
+ host = val
130
+ } else if (len === 9 && !forwarded && key.toLowerCase() === 'forwarded') {
131
+ forwarded = val
132
+ } else if (len === 10 && !connection && key.toLowerCase() === 'connection') {
133
+ connection = val
134
+ } else if (len === 10 && !authority && key.toLowerCase() === ':authority') {
135
+ authority = val
136
+ }
137
+ })
138
+
139
+ let remove = []
140
+ if (connection && !HOP_EXPR.test(connection)) {
141
+ remove = connection.split(/,\s*/)
142
+ }
143
+
144
+ forEachHeader(headers, (key, val) => {
145
+ if (key.charAt(0) !== ':' && !remove.includes(key) && !HOP_EXPR.test(key)) {
146
+ acc = fn(acc, key, val)
147
+ }
148
+ })
149
+
150
+ if (socket) {
151
+ const forwardedHost = authority || host
152
+ acc = fn(
153
+ acc,
154
+ 'forwarded',
155
+ (forwarded ? forwarded + ', ' : '') +
156
+ [
157
+ socket.localAddress && `by=${printIp(socket.localAddress, socket.localPort)}`,
158
+ socket.remoteAddress && `for=${printIp(socket.remoteAddress, socket.remotePort)}`,
159
+ `proto=${socket.encrypted ? 'https' : 'http'}`,
160
+ forwardedHost && `host="${forwardedHost}"`,
161
+ ].join(';'),
162
+ )
163
+ } else if (forwarded) {
164
+ // The forwarded header should not be included in response.
165
+ throw new createError.BadGateway()
166
+ }
167
+
168
+ if (proxyName) {
169
+ if (via) {
170
+ if (via.split(',').some((name) => name.endsWith(proxyName))) {
171
+ throw new createError.LoopDetected()
172
+ }
173
+ via += ', '
174
+ } else {
175
+ via = ''
176
+ }
177
+ via += `${httpVersion ?? 'HTTP/1.1'} ${proxyName}`
178
+ }
179
+
180
+ if (via) {
181
+ acc = fn(acc, 'via', via)
182
+ }
183
+
184
+ return acc
185
+ }
186
+
187
+ function printIp(address, port) {
188
+ const isIPv6 = net.isIPv6(address)
189
+ let str = `${address}`
190
+ if (isIPv6) {
191
+ str = `[${str}]`
192
+ }
193
+ if (port) {
194
+ str = `${str}:${port}`
195
+ }
196
+ if (isIPv6 || port) {
197
+ str = `"${str}"`
198
+ }
199
+ return str
200
+ }
@@ -0,0 +1,184 @@
1
+ const assert = require('assert')
2
+ const { findHeader, isDisturbed, parseURL } = require('../utils')
3
+
4
+ const redirectableStatusCodes = [300, 301, 302, 303, 307, 308]
5
+
6
+ class Handler {
7
+ constructor(opts, { dispatch, handler }) {
8
+ this.dispatch = dispatch
9
+ this.handler = handler
10
+ this.opts = opts
11
+ this.abort = null
12
+ this.aborted = false
13
+ this.reason = null
14
+
15
+ this.count = 0
16
+ this.location = null
17
+
18
+ this.handler.onConnect((reason) => {
19
+ this.aborted = true
20
+ if (this.abort) {
21
+ this.abort(reason)
22
+ } else {
23
+ this.reason = reason
24
+ }
25
+ })
26
+ }
27
+
28
+ onConnect(abort) {
29
+ if (this.aborted) {
30
+ abort(this.reason)
31
+ } else {
32
+ this.abort = abort
33
+ }
34
+ }
35
+
36
+ onUpgrade(statusCode, headers, socket) {
37
+ this.handler.onUpgrade(statusCode, headers, socket)
38
+ }
39
+
40
+ onError(error) {
41
+ this.handler.onError(error)
42
+ }
43
+
44
+ onHeaders(statusCode, headers, resume, statusText) {
45
+ if (redirectableStatusCodes.indexOf(statusCode) === -1) {
46
+ return this.handler.onHeaders(statusCode, headers, resume, statusText)
47
+ }
48
+
49
+ if (isDisturbed(this.opts.body)) {
50
+ throw new Error(`Disturbed request cannot be redirected.`)
51
+ }
52
+
53
+ this.location = findHeader(headers, 'location')
54
+
55
+ if (!this.location) {
56
+ throw new Error(`Missing redirection location.`)
57
+ }
58
+
59
+ this.count += 1
60
+
61
+ if (typeof this.opts.follow === 'function') {
62
+ if (!this.opts.follow(this.location, this.count)) {
63
+ return this.handler.onHeaders(statusCode, headers, resume, statusText)
64
+ }
65
+ } else {
66
+ const maxCount = this.opts.follow.count ?? 0
67
+
68
+ if (this.count >= maxCount) {
69
+ throw new Error(`Max redirections reached: ${maxCount}.`)
70
+ }
71
+ }
72
+
73
+ const { origin, pathname, search } = parseURL(
74
+ new URL(this.location, this.opts.origin && new URL(this.opts.path, this.opts.origin)),
75
+ )
76
+ const path = search ? `${pathname}${search}` : pathname
77
+
78
+ // Remove headers referring to the original URL.
79
+ // By default it is Host only, unless it's a 303 (see below), which removes also all Content-* headers.
80
+ // https://tools.ietf.org/html/rfc7231#section-6.4
81
+ this.opts = {
82
+ ...this.opts,
83
+ headers: cleanRequestHeaders(
84
+ this.opts.headers,
85
+ statusCode === 303,
86
+ this.opts.origin !== origin,
87
+ ),
88
+ path,
89
+ origin,
90
+ query: null,
91
+ }
92
+
93
+ // https://tools.ietf.org/html/rfc7231#section-6.4.4
94
+ // In case of HTTP 303, always replace method to be either HEAD or GET
95
+ if (statusCode === 303 && this.opts.method !== 'HEAD') {
96
+ this.opts = { ...this.opts, method: 'GET', body: null }
97
+ }
98
+ }
99
+
100
+ onData(chunk) {
101
+ if (this.location) {
102
+ /*
103
+ https://tools.ietf.org/html/rfc7231#section-6.4
104
+
105
+ TLDR: undici always ignores 3xx response bodies.
106
+
107
+ Redirection is used to serve the requested resource from another URL, so it is assumes that
108
+ no body is generated (and thus can be ignored). Even though generating a body is not prohibited.
109
+
110
+ For status 301, 302, 303, 307 and 308 (the latter from RFC 7238), the specs mention that the body usually
111
+ (which means it's optional and not mandated) contain just an hyperlink to the value of
112
+ the Location response header, so the body can be ignored safely.
113
+
114
+ For status 300, which is "Multiple Choices", the spec mentions both generating a Location
115
+ response header AND a response body with the other possible location to follow.
116
+ Since the spec explicitily chooses not to specify a format for such body and leave it to
117
+ servers and browsers implementors, we ignore the body as there is no specified way to eventually parse it.
118
+ */
119
+ } else {
120
+ return this.handler.onData(chunk)
121
+ }
122
+ }
123
+
124
+ onComplete(trailers) {
125
+ if (this.location) {
126
+ /*
127
+ https://tools.ietf.org/html/rfc7231#section-6.4
128
+
129
+ TLDR: undici always ignores 3xx response trailers as they are not expected in case of redirections
130
+ and neither are useful if present.
131
+
132
+ See comment on onData method above for more detailed informations.
133
+ */
134
+
135
+ this.location = null
136
+
137
+ this.dispatch(this.opts, this)
138
+ } else {
139
+ this.handler.onComplete(trailers)
140
+ }
141
+ }
142
+
143
+ onBodySent(chunk) {
144
+ if (this.handler.onBodySent) {
145
+ this.handler.onBodySent(chunk)
146
+ }
147
+ }
148
+ }
149
+
150
+ // https://tools.ietf.org/html/rfc7231#section-6.4.4
151
+ function shouldRemoveHeader(header, removeContent, unknownOrigin) {
152
+ return (
153
+ (header.length === 4 && header.toString().toLowerCase() === 'host') ||
154
+ (removeContent && header.toString().toLowerCase().indexOf('content-') === 0) ||
155
+ (unknownOrigin &&
156
+ header.length === 13 &&
157
+ header.toString().toLowerCase() === 'authorization') ||
158
+ (unknownOrigin && header.length === 6 && header.toString().toLowerCase() === 'cookie')
159
+ )
160
+ }
161
+
162
+ // https://tools.ietf.org/html/rfc7231#section-6.4
163
+ function cleanRequestHeaders(headers, removeContent, unknownOrigin) {
164
+ const ret = []
165
+ if (Array.isArray(headers)) {
166
+ for (let i = 0; i < headers.length; i += 2) {
167
+ if (!shouldRemoveHeader(headers[i], removeContent, unknownOrigin)) {
168
+ ret.push(headers[i], headers[i + 1])
169
+ }
170
+ }
171
+ } else if (headers && typeof headers === 'object') {
172
+ for (const key of Object.keys(headers)) {
173
+ if (!shouldRemoveHeader(key, removeContent, unknownOrigin)) {
174
+ ret.push(key, headers[key])
175
+ }
176
+ }
177
+ } else {
178
+ assert(headers == null, 'headers must be an object or an array')
179
+ }
180
+ return ret
181
+ }
182
+
183
+ module.exports = (dispatch) => (opts, handler) =>
184
+ opts.follow ? dispatch(opts, new Handler(opts, { handler, dispatch })) : dispatch(opts, handler)
@@ -0,0 +1,158 @@
1
+ const assert = require('node:assert')
2
+ const { parseContentRange, isDisturbed, findHeader, retry: retryFn } = require('../utils.js')
3
+
4
+ class Handler {
5
+ constructor(opts, { dispatch, handler }) {
6
+ this.dispatch = dispatch
7
+ this.handler = handler
8
+ this.opts = opts
9
+ this.abort = null
10
+ this.aborted = false
11
+ this.reason = null
12
+
13
+ this.count = 0
14
+ this.pos = 0
15
+ this.end = null
16
+ this.error = null
17
+ this.etag = null
18
+
19
+ this.handler.onConnect((reason) => {
20
+ this.aborted = true
21
+ if (this.abort) {
22
+ this.abort(reason)
23
+ } else {
24
+ this.reason = reason
25
+ }
26
+ })
27
+ }
28
+
29
+ onConnect(abort) {
30
+ if (this.aborted) {
31
+ abort(this.reason)
32
+ } else {
33
+ this.abort = abort
34
+ }
35
+ }
36
+
37
+ onUpgrade(statusCode, rawHeaders, socket) {
38
+ return this.handler.onUpgrade(statusCode, rawHeaders, socket)
39
+ }
40
+
41
+ onBodySent(chunk) {
42
+ return this.handler.onBodySent(chunk)
43
+ }
44
+
45
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
46
+ const etag = findHeader(rawHeaders, 'etag')
47
+
48
+ if (this.resume) {
49
+ this.resume = null
50
+
51
+ // TODO (fix): Support other statusCode with skip?
52
+ if (statusCode !== 206) {
53
+ throw this.error
54
+ }
55
+
56
+ // TODO (fix): strict vs weak etag?
57
+ if (this.etag == null || this.etag !== etag) {
58
+ throw this.error
59
+ }
60
+
61
+ const contentRange = parseContentRange(findHeader(rawHeaders, 'content-range'))
62
+ if (!contentRange) {
63
+ throw this.error
64
+ }
65
+
66
+ const { start, size, end = size } = contentRange
67
+
68
+ assert(this.pos === start, 'content-range mismatch')
69
+ assert(this.end == null || this.end === end, 'content-range mismatch')
70
+
71
+ this.resume = resume
72
+ return true
73
+ }
74
+
75
+ if (this.end == null) {
76
+ if (statusCode === 206) {
77
+ const contentRange = parseContentRange(findHeader(rawHeaders, 'content-range'))
78
+ if (!contentRange) {
79
+ return this.handler.onHeaders(statusCode, rawHeaders, () => this.resume(), statusMessage)
80
+ }
81
+
82
+ const { start, size, end = size } = contentRange
83
+
84
+ this.end = end
85
+ this.pos = Number(start)
86
+ } else {
87
+ const contentLength = findHeader(rawHeaders, 'content-length')
88
+ if (contentLength) {
89
+ this.end = Number(contentLength)
90
+ }
91
+ }
92
+
93
+ assert(Number.isFinite(this.pos))
94
+ assert(this.end == null || Number.isFinite(this.end), 'invalid content-length')
95
+ }
96
+
97
+ this.etag = etag
98
+ this.resume = resume
99
+ return this.handler.onHeaders(statusCode, rawHeaders, () => this.resume(), statusMessage)
100
+ }
101
+
102
+ onData(chunk) {
103
+ this.pos += chunk.length
104
+ this.count = 0
105
+ return this.handler.onData(chunk)
106
+ }
107
+
108
+ onComplete(rawTrailers) {
109
+ return this.handler.onComplete(rawTrailers)
110
+ }
111
+
112
+ onError(err) {
113
+ if (this.timeout) {
114
+ clearTimeout(this.timeout)
115
+ this.timeout = null
116
+ }
117
+
118
+ if (!this.resume || this.aborted || !this.etag || isDisturbed(this.opts.body)) {
119
+ return this.handler.onError(err)
120
+ }
121
+
122
+ const retryPromise = retryFn(err, this.count++, this.opts)
123
+ if (retryPromise == null) {
124
+ return this.handler.onError(err)
125
+ }
126
+
127
+ retryPromise
128
+ .then(() => {
129
+ this.timeout = null
130
+ try {
131
+ this.dispatch(this.opts, this)
132
+ } catch (err2) {
133
+ this.handler.onError(err)
134
+ }
135
+ })
136
+ .catch((err) => {
137
+ this.handler.onError(err)
138
+ })
139
+
140
+ this.error = err
141
+ this.opts = {
142
+ ...this.opts,
143
+ headers: {
144
+ ...this.opts.headers,
145
+ 'if-match': this.etag,
146
+ range: `bytes=${this.pos}-${this.end ?? ''}`,
147
+ },
148
+ }
149
+
150
+ this.opts.loggerdebug('retrying response body')
151
+ }
152
+ }
153
+
154
+ module.exports = (dispatch) => (opts, handler) => {
155
+ return opts.idempotent && opts.retry && opts.method === 'GET' && !opts.upgrade
156
+ ? dispatch(opts, new Handler(opts, { handler, dispatch }))
157
+ : dispatch(opts, handler)
158
+ }
@@ -0,0 +1,89 @@
1
+ const { isDisturbed, retry: retryFn } = require('../utils.js')
2
+
3
+ class Handler {
4
+ constructor(opts, { dispatch, handler }) {
5
+ this.dispatch = dispatch
6
+ this.handler = handler
7
+ this.opts = opts
8
+ this.abort = null
9
+ this.aborted = false
10
+ this.reason = null
11
+ this.timeout = null
12
+ this.count = 0
13
+ this.retryAfter = null
14
+
15
+ this.handler.onConnect((reason) => {
16
+ this.aborted = true
17
+ if (this.abort) {
18
+ this.abort(reason)
19
+ } else {
20
+ this.reason = reason
21
+ }
22
+ })
23
+ }
24
+
25
+ onConnect(abort) {
26
+ if (this.aborted) {
27
+ abort(this.reason)
28
+ } else {
29
+ this.abort = abort
30
+ }
31
+ }
32
+
33
+ onUpgrade(statusCode, rawHeaders, socket) {
34
+ return this.handler.onUpgrade(statusCode, rawHeaders, socket)
35
+ }
36
+
37
+ onBodySent(chunk) {
38
+ return this.handler.onBodySent(chunk)
39
+ }
40
+
41
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
42
+ this.aborted = true
43
+ return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
44
+ }
45
+
46
+ onData(chunk) {
47
+ return this.handler.onData(chunk)
48
+ }
49
+
50
+ onComplete(rawTrailers) {
51
+ return this.handler.onComplete(rawTrailers)
52
+ }
53
+
54
+ onError(err) {
55
+ if (this.timeout) {
56
+ clearTimeout(this.timeout)
57
+ this.timeout = null
58
+ }
59
+
60
+ if (this.aborted || isDisturbed(this.opts.body)) {
61
+ return this.handler.onError(err)
62
+ }
63
+
64
+ const retryPromise = retryFn(err, this.count++, this.opts)
65
+ if (retryPromise == null) {
66
+ return this.handler.onError(err)
67
+ }
68
+
69
+ retryPromise
70
+ .then(() => {
71
+ this.timeout = null
72
+ try {
73
+ this.dispatch(this.opts, this)
74
+ } catch (err2) {
75
+ this.handler.onError(err)
76
+ }
77
+ })
78
+ .catch((err) => {
79
+ this.handler.onError(err)
80
+ })
81
+
82
+ this.opts.loggerdebug('retrying response')
83
+ }
84
+ }
85
+
86
+ module.exports = (dispatch) => (opts, handler) =>
87
+ opts.idempotent && opts.retry
88
+ ? dispatch(opts, new Handler(opts, { handler, dispatch }))
89
+ : dispatch(opts, handler)