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