@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,155 @@
1
+ const assert = require('node:assert')
2
+ const {
3
+ parseContentRange,
4
+ isDisturbed,
5
+ findHeader,
6
+ retryAfter: retryAfterFn,
7
+ } = require('../utils.js')
8
+
9
+ class Handler {
10
+ constructor(opts, { dispatch, handler }) {
11
+ this.dispatch = dispatch
12
+ this.handler = handler
13
+ this.opts = opts
14
+ this.abort = null
15
+ this.aborted = false
16
+ this.reason = null
17
+
18
+ this.count = 0
19
+ this.pos = 0
20
+ this.end = null
21
+ this.error = null
22
+ this.etag = null
23
+
24
+ this.handler.onConnect((reason) => {
25
+ this.aborted = true
26
+ if (this.abort) {
27
+ this.abort(reason)
28
+ } else {
29
+ this.reason = reason
30
+ }
31
+ })
32
+ }
33
+
34
+ onConnect(abort) {
35
+ if (this.aborted) {
36
+ abort(this.reason)
37
+ } else {
38
+ this.abort = abort
39
+ }
40
+ }
41
+
42
+ onBodySent(chunk) {
43
+ return this.handler.onBodySent(chunk)
44
+ }
45
+
46
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
47
+ const etag = findHeader(rawHeaders, 'etag')
48
+
49
+ if (this.resume) {
50
+ this.resume = null
51
+
52
+ // TODO (fix): Support other statusCode with skip?
53
+ if (statusCode !== 206) {
54
+ throw this.error
55
+ }
56
+
57
+ // TODO (fix): strict vs weak etag?
58
+ if (this.etag == null || this.etag !== etag) {
59
+ throw this.error
60
+ }
61
+
62
+ const contentRange = parseContentRange(findHeader(rawHeaders, 'content-range'))
63
+ if (!contentRange) {
64
+ throw this.error
65
+ }
66
+
67
+ const { start, size, end = size } = contentRange
68
+
69
+ assert(this.pos === start, 'content-range mismatch')
70
+ assert(this.end == null || this.end === end, 'content-range mismatch')
71
+
72
+ this.resume = resume
73
+ return true
74
+ }
75
+
76
+ if (this.end == null) {
77
+ if (statusCode === 206) {
78
+ const contentRange = parseContentRange(findHeader(rawHeaders, 'content-range'))
79
+ if (!contentRange) {
80
+ return this.handler.onHeaders(statusCode, rawHeaders, () => this.resume(), statusMessage)
81
+ }
82
+
83
+ const { start, size, end = size } = contentRange
84
+
85
+ this.end = end
86
+ this.pos = Number(start)
87
+ } else {
88
+ const contentLength = findHeader(rawHeaders, 'content-length')
89
+ if (contentLength) {
90
+ this.end = Number(contentLength)
91
+ }
92
+ }
93
+
94
+ assert(Number.isFinite(this.pos))
95
+ assert(this.end == null || Number.isFinite(this.end), 'invalid content-length')
96
+ }
97
+
98
+ this.etag = etag
99
+ this.resume = resume
100
+ return this.handler.onHeaders(statusCode, rawHeaders, () => this.resume(), statusMessage)
101
+ }
102
+
103
+ onData(chunk) {
104
+ this.pos += chunk.length
105
+ this.count = 0
106
+ return this.handler.onData(chunk)
107
+ }
108
+
109
+ onComplete(rawTrailers) {
110
+ return this.handler.onComplete(rawTrailers)
111
+ }
112
+
113
+ onError(err) {
114
+ if (this.timeout) {
115
+ clearTimeout(this.timeout)
116
+ this.timeout = null
117
+ }
118
+
119
+ if (!this.resume || this.aborted || !this.etag || isDisturbed(this.opts.body)) {
120
+ return this.handler.onError(err)
121
+ }
122
+
123
+ const retryAfter = retryAfterFn(err, this.count++, this.opts)
124
+ if (retryAfter == null) {
125
+ return this.handler.onError(err)
126
+ }
127
+ assert(Number.isFinite(retryAfter), 'invalid retry')
128
+
129
+ this.error = err
130
+ this.opts = {
131
+ ...this.opts,
132
+ headers: {
133
+ ...this.opts.headers,
134
+ range: `bytes=${this.pos}-${this.end ?? ''}`,
135
+ },
136
+ }
137
+
138
+ this.opts.logger?.debug('retrying response body', { retryAfter })
139
+
140
+ this.timeout = setTimeout(() => {
141
+ this.timeout = null
142
+ try {
143
+ this.dispatch(this.opts, this)
144
+ } catch (err) {
145
+ this.handler.onError(err)
146
+ }
147
+ }, retryAfter)
148
+ }
149
+ }
150
+
151
+ module.exports = (dispatch) => (opts, handler) => {
152
+ return opts.idempotent && opts.retry && opts.method === 'GET'
153
+ ? dispatch(opts, new Handler(opts, { handler, dispatch }))
154
+ : dispatch(opts, handler)
155
+ }
@@ -0,0 +1,84 @@
1
+ const assert = require('node:assert')
2
+ const { isDisturbed, retryAfter: retryAfterFn } = 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
+ this.timeout = null
13
+ this.count = 0
14
+ this.retryAfter = null
15
+
16
+ this.handler.onConnect((reason) => {
17
+ this.aborted = true
18
+ if (this.abort) {
19
+ this.abort(reason)
20
+ } else {
21
+ this.reason = reason
22
+ }
23
+ })
24
+ }
25
+
26
+ onConnect(abort) {
27
+ if (this.aborted) {
28
+ abort(this.reason)
29
+ } else {
30
+ this.abort = abort
31
+ }
32
+ }
33
+
34
+ onBodySent(chunk) {
35
+ return this.handler.onBodySent(chunk)
36
+ }
37
+
38
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
39
+ this.aborted = true
40
+ return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
41
+ }
42
+
43
+ onData(chunk) {
44
+ return this.handler.onData(chunk)
45
+ }
46
+
47
+ onComplete(rawTrailers) {
48
+ return this.handler.onComplete(rawTrailers)
49
+ }
50
+
51
+ onError(err) {
52
+ if (this.timeout) {
53
+ clearTimeout(this.timeout)
54
+ this.timeout = null
55
+ }
56
+
57
+ if (this.aborted || isDisturbed(this.opts.body)) {
58
+ return this.handler.onError(err)
59
+ }
60
+
61
+ const retryAfter = retryAfterFn(err, this.count++, this.opts)
62
+ if (retryAfter == null) {
63
+ return this.handler.onError(err)
64
+ }
65
+ assert(Number.isFinite(retryAfter), 'invalid retryAfter')
66
+
67
+ this.opts.logger?.debug('retrying response', { retryAfter })
68
+
69
+ this.timeout = setTimeout(() => {
70
+ this.timeout = null
71
+ try {
72
+ this.dispatch(this.opts, this)
73
+ } catch (err2) {
74
+ this.handler.onError(err)
75
+ }
76
+ }, retryAfter)
77
+ this.retryAfter = null
78
+ }
79
+ }
80
+
81
+ module.exports = (dispatch) => (opts, handler) =>
82
+ opts.idempotent && opts.retry
83
+ ? dispatch(opts, new Handler(opts, { handler, dispatch }))
84
+ : dispatch(opts, handler)
@@ -0,0 +1,96 @@
1
+ const assert = require('node:assert')
2
+ const { isDisturbed, retryAfter: retryAfterFn } = require('../utils')
3
+ const createError = require('http-errors')
4
+ const { parseHeaders } = require('../../http')
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.timeout = null
16
+ this.count = 0
17
+ this.retryAfter = 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
+ onBodySent(chunk) {
38
+ return this.handler.onBodySent(chunk)
39
+ }
40
+
41
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
42
+ if (statusCode < 400) {
43
+ return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
44
+ }
45
+
46
+ const err = createError(statusCode, { headers: parseHeaders(rawHeaders) })
47
+
48
+ const retryAfter = retryAfterFn(err, this.count++, this.opts)
49
+ if (retryAfter == null) {
50
+ return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
51
+ }
52
+ assert(Number.isFinite(retryAfter), 'invalid retryAfter')
53
+
54
+ this.retryAfter = retryAfter
55
+
56
+ this.abort(err)
57
+
58
+ return false
59
+ }
60
+
61
+ onData(chunk) {
62
+ return this.handler.onData(chunk)
63
+ }
64
+
65
+ onComplete(rawTrailers) {
66
+ return this.handler.onComplete(rawTrailers)
67
+ }
68
+
69
+ onError(err) {
70
+ if (this.timeout) {
71
+ clearTimeout(this.timeout)
72
+ this.timeout = null
73
+ }
74
+
75
+ if (this.retryAfter == null || this.aborted || isDisturbed(this.opts.body)) {
76
+ return this.handler.onError(err)
77
+ }
78
+
79
+ this.opts.logger?.debug('retrying response status', { retryAfter: this.retryAfter })
80
+
81
+ this.timeout = setTimeout(() => {
82
+ this.timeout = null
83
+ try {
84
+ this.dispatch(this.opts, this)
85
+ } catch (err) {
86
+ this.handler.onError(err)
87
+ }
88
+ }, this.retryAfter)
89
+ this.retryAfter = null
90
+ }
91
+ }
92
+
93
+ module.exports = (dispatch) => (opts, handler) =>
94
+ opts.idempotent && opts.retry
95
+ ? dispatch(opts, new Handler(opts, { handler, dispatch }))
96
+ : dispatch(opts, handler)
@@ -0,0 +1,47 @@
1
+ class Handler {
2
+ constructor(opts, signal, { handler }) {
3
+ this.handler = handler
4
+ this.signal = signal
5
+ this.abort = null
6
+ }
7
+
8
+ onConnect(abort) {
9
+ this.abort = () => {
10
+ abort(this.signal.reason)
11
+ }
12
+ this.signal.addEventListener('abort', this.abort)
13
+
14
+ if (this.signal.aborted) {
15
+ abort(this.signal.reason)
16
+ } else {
17
+ this.handler.onConnect(abort)
18
+ }
19
+ }
20
+
21
+ onBodySent(chunk) {
22
+ return this.handler.onBodySent(chunk)
23
+ }
24
+
25
+ onHeaders(statusCode, rawHeaders, resume, statusMessage) {
26
+ return this.handler.onHeaders(statusCode, rawHeaders, resume, statusMessage)
27
+ }
28
+
29
+ onData(chunk) {
30
+ return this.handler.onData(chunk)
31
+ }
32
+
33
+ onComplete(rawTrailers) {
34
+ this.signal.removeEventListener('abort', this.abort)
35
+ return this.handler.onComplete(rawTrailers)
36
+ }
37
+
38
+ onError(err) {
39
+ this.signal.removeEventListener('abort', this.abort)
40
+ return this.handler.onError(err)
41
+ }
42
+ }
43
+
44
+ module.exports =
45
+ (dispatch) =>
46
+ ({ signal, ...opts }, handler) =>
47
+ signal ? dispatch(opts, new Handler(opts, signal, { handler })) : dispatch(opts, handler)
@@ -0,0 +1,171 @@
1
+ function isDisturbed(body) {
2
+ return !(body == null || typeof body === 'string' || Buffer.isBuffer(body))
3
+ }
4
+
5
+ function parseContentRange(range) {
6
+ if (typeof range !== 'string') {
7
+ return null
8
+ }
9
+
10
+ const m = range.match(/^bytes (\d+)-(\d+)?\/(\d+|\*)$/)
11
+ if (!m) {
12
+ return null
13
+ }
14
+
15
+ const start = m[1] == null ? null : Number(m[1])
16
+ if (!Number.isFinite(start)) {
17
+ return null
18
+ }
19
+
20
+ const end = m[2] == null ? null : Number(m[2])
21
+ if (end !== null && !Number.isFinite(end)) {
22
+ return null
23
+ }
24
+
25
+ const size = m[2] === '*' ? null : Number(m[2])
26
+ if (size !== null && !Number.isFinite(size)) {
27
+ return null
28
+ }
29
+
30
+ return { start, end: end + 1, size }
31
+ }
32
+
33
+ function findHeader(rawHeaders, name) {
34
+ const len = name.length
35
+
36
+ for (let i = 0; i < rawHeaders.length; i += 2) {
37
+ const key = rawHeaders[i + 0]
38
+ if (key.length === len && key.toString().toLowerCase() === name) {
39
+ return rawHeaders[i + 1].toString()
40
+ }
41
+ }
42
+ return null
43
+ }
44
+
45
+ function retryAfter(err, retryCount, opts) {
46
+ if (opts.retry === null || opts.retry === false) {
47
+ return null
48
+ }
49
+
50
+ if (typeof opts.retry === 'function') {
51
+ const ret = opts.retry(err, retryCount)
52
+ if (ret != null) {
53
+ return ret
54
+ }
55
+ }
56
+
57
+ const retryMax = opts.retry?.count ?? opts.maxRetries ?? 8
58
+
59
+ if (retryCount > retryMax) {
60
+ return null
61
+ }
62
+
63
+ if (err.statusCode && [420, 429, 502, 503, 504].includes(err.statusCode)) {
64
+ const retryAfter = err.headers['retry-after'] ? err.headers['retry-after'] * 1e3 : null
65
+ return retryAfter ?? Math.min(10e3, retryCount * 1e3)
66
+ }
67
+
68
+ if (
69
+ err.code &&
70
+ [
71
+ 'ECONNRESET',
72
+ 'ECONNREFUSED',
73
+ 'ENOTFOUND',
74
+ 'ENETDOWN',
75
+ 'ENETUNREACH',
76
+ 'EHOSTDOWN',
77
+ 'EHOSTUNREACH',
78
+ 'EPIPE',
79
+ ].includes(err.code)
80
+ ) {
81
+ return Math.min(10e3, retryCount * 1e3)
82
+ }
83
+
84
+ if (err.message && ['other side closed'].includes(err.message)) {
85
+ return Math.min(10e3, retryCount * 1e3)
86
+ }
87
+
88
+ return null
89
+ }
90
+
91
+ function parseURL(url) {
92
+ if (typeof url === 'string') {
93
+ url = new URL(url)
94
+
95
+ if (!/^https?:/.test(url.origin || url.protocol)) {
96
+ throw new Error('Invalid URL protocol: the URL must start with `http:` or `https:`.')
97
+ }
98
+
99
+ return url
100
+ }
101
+
102
+ if (!url || typeof url !== 'object') {
103
+ throw new Error('Invalid URL: The URL argument must be a non-null object.')
104
+ }
105
+
106
+ if (url.port != null && url.port !== '' && !Number.isFinite(parseInt(url.port))) {
107
+ throw new Error(
108
+ 'Invalid URL: port must be a valid integer or a string representation of an integer.',
109
+ )
110
+ }
111
+
112
+ if (url.path != null && typeof url.path !== 'string') {
113
+ throw new Error('Invalid URL path: the path must be a string or null/undefined.')
114
+ }
115
+
116
+ if (url.pathname != null && typeof url.pathname !== 'string') {
117
+ throw new Error('Invalid URL pathname: the pathname must be a string or null/undefined.')
118
+ }
119
+
120
+ if (url.hostname != null && typeof url.hostname !== 'string') {
121
+ throw new Error('Invalid URL hostname: the hostname must be a string or null/undefined.')
122
+ }
123
+
124
+ if (url.origin != null && typeof url.origin !== 'string') {
125
+ throw new Error('Invalid URL origin: the origin must be a string or null/undefined.')
126
+ }
127
+
128
+ if (!/^https?:/.test(url.origin || url.protocol)) {
129
+ throw new Error('Invalid URL protocol: the URL must start with `http:` or `https:`.')
130
+ }
131
+
132
+ if (!(url instanceof URL)) {
133
+ const port = url.port != null ? url.port : url.protocol === 'https:' ? 443 : 80
134
+ let origin = url.origin != null ? url.origin : `${url.protocol}//${url.hostname}:${port}`
135
+ let path = url.path != null ? url.path : `${url.pathname || ''}${url.search || ''}`
136
+
137
+ if (origin.endsWith('/')) {
138
+ origin = origin.substring(0, origin.length - 1)
139
+ }
140
+
141
+ if (path && !path.startsWith('/')) {
142
+ path = `/${path}`
143
+ }
144
+ // new URL(path, origin) is unsafe when `path` contains an absolute URL
145
+ // From https://developer.mozilla.org/en-US/docs/Web/API/URL/URL:
146
+ // If first parameter is a relative URL, second param is required, and will be used as the base URL.
147
+ // If first parameter is an absolute URL, a given second param will be ignored.
148
+ url = new URL(origin + path)
149
+ }
150
+
151
+ return url
152
+ }
153
+
154
+ function parseOrigin(url) {
155
+ url = module.exports.parseURL(url)
156
+
157
+ if (url.pathname !== '/' || url.search || url.hash) {
158
+ throw new Error('invalid url')
159
+ }
160
+
161
+ return url
162
+ }
163
+
164
+ module.exports = {
165
+ isDisturbed,
166
+ parseContentRange,
167
+ findHeader,
168
+ retryAfter,
169
+ parseURL,
170
+ parseOrigin,
171
+ }
package/undici.js DELETED
@@ -1,160 +0,0 @@
1
- const assert = require('assert')
2
- const { errorMonitor } = require('node:events')
3
- const tp = require('timers/promises')
4
- const xuid = require('xuid')
5
- const { isReadableNodeStream, readableStreamLength } = require('./stream')
6
- const undici = require('undici')
7
- const stream = require('stream')
8
- const omitEmpty = require('omit-empty')
9
-
10
- module.exports.request = async function request(
11
- url,
12
- {
13
- logger,
14
- id = xuid(),
15
- retry,
16
- maxRedirections: _maxRedirections,
17
- idempotent,
18
- redirect,
19
- dispatcher,
20
- signal,
21
- headersTimeout,
22
- bodyTimeout,
23
- reset = false,
24
- body,
25
- method = body ? 'POST' : 'GET',
26
- userAgent,
27
- headers,
28
- dump = method === 'HEAD',
29
- }
30
- ) {
31
- if (retry === false) {
32
- retry = { count: 0 }
33
- } else if (typeof retry === 'number') {
34
- retry = { count: retry }
35
- }
36
-
37
- if (redirect === false) {
38
- redirect = { count: 0 }
39
- } else if (typeof redirect === 'number') {
40
- redirect = { count: redirect }
41
- }
42
-
43
- const { count: maxRedirections = _maxRedirections ?? 3 } = redirect ?? {}
44
- const {
45
- count: maxRetries = 8,
46
- method: retryMethod = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'PATCH'],
47
- status: retryStatus = [420, 429, 502, 503, 504],
48
- code: retryCode = [
49
- 'ECONNRESET',
50
- 'ECONNREFUSED',
51
- 'ENOTFOUND',
52
- 'ENETDOWN',
53
- 'ENETUNREACH',
54
- 'EHOSTDOWN',
55
- 'EHOSTUNREACH',
56
- 'EPIPE',
57
- ],
58
- message: retryMessage = ['other side closed'],
59
- } = retry ?? {}
60
-
61
- if (readableStreamLength(body) === 0) {
62
- body.on('error', () => {})
63
- body = null
64
- }
65
-
66
- const ureq = {
67
- url,
68
- method,
69
- body,
70
- headers: omitEmpty({
71
- 'request-id': id,
72
- 'user-agent': userAgent,
73
- ...headers,
74
- }),
75
- }
76
-
77
- let upstreamLogger = logger?.child({ ureq })
78
-
79
- upstreamLogger?.debug('upstream request started')
80
-
81
- try {
82
- /* eslint-disable no-unreachable-loop */
83
- for (let retryCount = 0; true; retryCount++) {
84
- let ures
85
- try {
86
- ures = await undici.request(url, {
87
- method,
88
- reset,
89
- body,
90
- headers,
91
- signal,
92
- dispatcher,
93
- maxRedirections,
94
- throwOnError: true,
95
- headersTimeout,
96
- bodyTimeout,
97
- })
98
-
99
- upstreamLogger = upstreamLogger?.child({ ures })
100
-
101
- upstreamLogger?.debug('upstream request response')
102
-
103
- if (ures.statusCode >= 300 && ures.statusCode < 400) {
104
- throw new Error('maxRedirections exceeded')
105
- }
106
-
107
- assert(ures.statusCode >= 200 && ures.statusCode < 300)
108
-
109
- if (dump) {
110
- await ures.body.dump()
111
- }
112
-
113
- ures.body.on(errorMonitor, (err) => {
114
- upstreamLogger?.debug({ err }, 'upstream request response body failed')
115
- })
116
-
117
- // TODO (fix): Wrap response to handle error that can continue with range request...
118
-
119
- return ures
120
- } catch (err) {
121
- await ures?.body.dump()
122
-
123
- if (retryCount >= maxRetries) {
124
- throw err
125
- }
126
-
127
- if (
128
- body != null &&
129
- typeof body !== 'string' &&
130
- !Buffer.isBuffer(body) &&
131
- (!isReadableNodeStream(body) || stream.isDisturbed(body))
132
- ) {
133
- throw err
134
- }
135
-
136
- if (!retryMethod.includes(method) && !idempotent) {
137
- throw err
138
- }
139
-
140
- if (
141
- !retryCode.includes(err.code) &&
142
- !retryMessage.includes(err.message) &&
143
- !retryStatus.includes(err.statusCode)
144
- ) {
145
- throw err
146
- }
147
-
148
- const delay =
149
- parseInt(err.headers?.['Retry-After']) * 1e3 || Math.min(10e3, retryCount * 1e3 + 1e3)
150
-
151
- upstreamLogger?.warn({ err, retryCount, delay }, 'upstream request retrying')
152
-
153
- await tp.setTimeout(delay, undefined, { signal })
154
- }
155
- }
156
- } catch (err) {
157
- upstreamLogger?.error({ err }, 'upstream request failed')
158
- throw err
159
- }
160
- }