@nxtedition/nxt-undici 7.4.2 → 7.5.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.
package/lib/utils.js CHANGED
@@ -1,4 +1,3 @@
1
- import cacheControlParser from 'cache-control-parser'
2
1
  import stream from 'node:stream'
3
2
  import assert from 'node:assert'
4
3
  import { util } from '@nxtedition/undici'
@@ -14,14 +13,295 @@ export function getFastNow() {
14
13
  return fastNow
15
14
  }
16
15
 
17
- export function parseCacheControl(str) {
18
- if (Array.isArray(str)) {
19
- // Cache-Control is a list-typed field, so duplicated field lines are legal
20
- // (e.g. CDN + origin each adding one) and combine into a single
21
- // comma-separated value per RFC 9110 §5.2.
22
- str = str.join(', ')
16
+ /**
17
+ * Vendored from undici's parseCacheControlHeader (lib/util/cache.js) with two
18
+ * deliberate deviations, both toward the conservative reading of RFC 9111:
19
+ * - duplicated max-age keeps the SMALLER value (§4.2.1 says a cache is free to
20
+ * pick, so pick the one that revalidates sooner), upstream keeps the larger;
21
+ * - a bare (valueless) max-stale is represented as Infinity per §5.2.1.2
22
+ * ("willing to accept a stale response of any age"), upstream drops it.
23
+ *
24
+ * Qualified no-cache/private (e.g. `no-cache="set-cookie"`) parse to an array
25
+ * of the listed field names; the unqualified forms parse to `true`. Callers
26
+ * that mean "unqualified" must check `=== true`, not truthiness.
27
+ *
28
+ * Keeps the historical wrapper contract: '', [], and non-strings return null
29
+ * (call sites rely on `?? {}`); an array of field lines is accepted directly
30
+ * (Cache-Control is list-typed, duplicated lines are legal per RFC 9110 §5.2).
31
+ *
32
+ * @param {string | string[] | null | undefined} header
33
+ * @returns {Record<string, boolean | number | string[]> | null}
34
+ */
35
+ export function parseCacheControl(header) {
36
+ let directives
37
+ if (Array.isArray(header)) {
38
+ directives = []
39
+ for (const line of header) {
40
+ if (typeof line !== 'string') {
41
+ return null
42
+ }
43
+ directives.push(...line.split(','))
44
+ }
45
+ if (directives.length === 0) {
46
+ return null
47
+ }
48
+ } else if (header && typeof header === 'string') {
49
+ directives = header.split(',')
50
+ } else {
51
+ return null
52
+ }
53
+
54
+ const output = {}
55
+
56
+ for (let i = 0; i < directives.length; i++) {
57
+ const directive = directives[i].toLowerCase()
58
+ const keyValueDelimiter = directive.indexOf('=')
59
+
60
+ let key
61
+ let value
62
+ if (keyValueDelimiter !== -1) {
63
+ key = directive.substring(0, keyValueDelimiter).trim()
64
+ value = directive.substring(keyValueDelimiter + 1)
65
+ } else {
66
+ key = directive.trim()
67
+ }
68
+
69
+ switch (key) {
70
+ case 'min-fresh':
71
+ case 'max-stale':
72
+ case 'max-age':
73
+ case 's-maxage':
74
+ case 'stale-while-revalidate':
75
+ case 'stale-if-error': {
76
+ if (value === undefined) {
77
+ if (key === 'max-stale') {
78
+ // Bare max-stale: any staleness is acceptable (RFC 9111
79
+ // §5.2.1.2). Number semantics keep call-site math
80
+ // (`staleAt + max-stale * 1000`) working unchanged.
81
+ output[key] = Infinity
82
+ }
83
+ continue
84
+ }
85
+
86
+ // RFC 9110 §5.6.3: a recipient may remove BWS/OWS around the value, so
87
+ // tolerate whitespace (`max-age= 60`, `max-age=60 `) rather than
88
+ // dropping the directive.
89
+ value = value.trim()
90
+
91
+ if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
92
+ value = value.substring(1, value.length - 1)
93
+ }
94
+
95
+ // RFC 9111 delta-seconds are 1*DIGIT: require the whole value to be
96
+ // digits so malformed inputs (`max-age=60junk`, `-1`, `1.5`) are
97
+ // dropped rather than parseInt-coerced into freshness math.
98
+ if (!/^\d+$/.test(value)) {
99
+ continue
100
+ }
101
+ const parsedValue = parseInt(value, 10)
102
+
103
+ if (key === 'max-age' && key in output && output[key] <= parsedValue) {
104
+ // Duplicate max-age: keep the smaller (sooner-stale) value.
105
+ continue
106
+ }
107
+
108
+ output[key] = parsedValue
109
+
110
+ break
111
+ }
112
+ case 'private':
113
+ case 'no-cache': {
114
+ if (value) {
115
+ // Qualified form: a quoted, possibly comma-separated list of field
116
+ // names (`no-cache="set-cookie, warning"`). The split-by-comma above
117
+ // may have cut the quoted list apart, so scan forward until the part
118
+ // that carries the closing quote. https://www.rfc-editor.org/rfc/rfc9111.html#name-no-cache-2
119
+ //
120
+ // The unqualified form is strictly more restrictive (forbids
121
+ // storing / demands validation for the WHOLE response), so when
122
+ // both forms appear (`private, private="x"` — invalid but seen in
123
+ // the wild), the qualified form must never clobber the `true`.
124
+ value = value.trim()
125
+ if (value[0] === '"') {
126
+ const headerNames = [value.substring(1)]
127
+
128
+ // length > 1: a lone `"` is an opening quote, not a closed list.
129
+ let foundEndingQuote = value.length > 1 && value[value.length - 1] === '"'
130
+ if (!foundEndingQuote) {
131
+ for (let j = i + 1; j < directives.length; j++) {
132
+ // Trim before the closing-quote check: optional whitespace
133
+ // between the quote and the next comma (`no-cache="a, b" ,x`)
134
+ // must not defeat the scan.
135
+ const nextPart = directives[j].trim()
136
+ headerNames.push(nextPart)
137
+ if (nextPart.length !== 0 && nextPart[nextPart.length - 1] === '"') {
138
+ foundEndingQuote = true
139
+ // Consume the scanned parts so a quoted fragment (e.g. a
140
+ // literal `max-age=1` inside the field list) is not
141
+ // re-parsed as a real directive.
142
+ i = j
143
+ break
144
+ }
145
+ }
146
+ }
147
+
148
+ if (foundEndingQuote) {
149
+ const lastHeader = headerNames[headerNames.length - 1]
150
+ if (lastHeader[lastHeader.length - 1] === '"') {
151
+ headerNames[headerNames.length - 1] = lastHeader.substring(0, lastHeader.length - 1)
152
+ }
153
+ if (output[key] !== true) {
154
+ const fields = headerNames.map((name) => name.trim().toLowerCase())
155
+ output[key] = Array.isArray(output[key]) ? output[key].concat(fields) : fields
156
+ }
157
+ } else {
158
+ // Unterminated quoted list (invalid header). Fail restrictive:
159
+ // treat as the unqualified form, and consume the remaining
160
+ // parts — they are fragments of the broken quoted string, not
161
+ // real directives.
162
+ output[key] = true
163
+ i = directives.length
164
+ }
165
+ } else {
166
+ // Unquoted value (e.g. `private=set-cookie`): the qualified form
167
+ // MUST be a quoted field-list (RFC 9111 §5.2.2.7). A bare token is
168
+ // malformed — fail restrictive and treat it as the unqualified
169
+ // directive (forbid storing / demand revalidation for the whole
170
+ // response) rather than a more permissive field list.
171
+ output[key] = true
172
+ }
173
+
174
+ break
175
+ }
176
+ }
177
+ // eslint-disable-next-line no-fallthrough
178
+ case 'public':
179
+ case 'no-store':
180
+ case 'must-revalidate':
181
+ case 'proxy-revalidate':
182
+ case 'immutable':
183
+ case 'no-transform':
184
+ case 'must-understand':
185
+ case 'only-if-cached':
186
+ // These directives take no value. An explicit `=` (even empty, e.g.
187
+ // `public=`) is an invalid qualified form and is ignored — not
188
+ // treated as the bare directive. `value === undefined` is the genuine
189
+ // valueless form (including the private/no-cache fallthrough above).
190
+ if (value !== undefined) {
191
+ continue
192
+ }
193
+
194
+ output[key] = true
195
+ break
196
+ default:
197
+ // Unknown directives are ignored per RFC 9111 §5.2.3.
198
+ continue
199
+ }
200
+ }
201
+
202
+ return output
203
+ }
204
+
205
+ const HTTP_DATE_MONTHS = {
206
+ Jan: 0,
207
+ Feb: 1,
208
+ Mar: 2,
209
+ Apr: 3,
210
+ May: 4,
211
+ Jun: 5,
212
+ Jul: 6,
213
+ Aug: 7,
214
+ Sep: 8,
215
+ Oct: 9,
216
+ Nov: 10,
217
+ Dec: 11,
218
+ }
219
+
220
+ const HTTP_DATE_DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
221
+ const HTTP_DATE_FULL_DAYS = [
222
+ 'Sunday',
223
+ 'Monday',
224
+ 'Tuesday',
225
+ 'Wednesday',
226
+ 'Thursday',
227
+ 'Friday',
228
+ 'Saturday',
229
+ ]
230
+
231
+ // IMF-fixdate: Sun, 06 Nov 1994 08:49:37 GMT
232
+ const IMF_DATE_RE =
233
+ /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/
234
+ // obsolete RFC 850: Sunday, 06-Nov-94 08:49:37 GMT
235
+ const RFC850_DATE_RE =
236
+ /^(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/
237
+ // ANSI C asctime(): Sun Nov 6 08:49:37 1994 (day space-padded)
238
+ const ASCTIME_DATE_RE =
239
+ /^(Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ([ \d]\d) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/
240
+
241
+ /**
242
+ * Strict HTTP-date parser per RFC 9110 §5.6.7 (the three accepted formats
243
+ * only). Deliberately NOT `new Date(str)`: V8 accepts many non-HTTP formats,
244
+ * and RFC 9111 §5.3 requires an invalid Expires (notably `Expires: 0`) to be
245
+ * treated as already expired rather than silently ignored — which needs the
246
+ * parse failure to be observable.
247
+ *
248
+ * @param {string} date
249
+ * @returns {Date | undefined} undefined when not a valid HTTP-date
250
+ */
251
+ export function parseHttpDate(date) {
252
+ if (typeof date !== 'string') {
253
+ return undefined
254
+ }
255
+
256
+ let weekday
257
+ let day
258
+ let month
259
+ let year
260
+ let hour
261
+ let minute
262
+ let second
263
+
264
+ let m = IMF_DATE_RE.exec(date)
265
+ if (m) {
266
+ weekday = HTTP_DATE_DAYS.indexOf(m[1])
267
+ day = Number(m[2])
268
+ month = HTTP_DATE_MONTHS[m[3]]
269
+ year = Number(m[4])
270
+ hour = Number(m[5])
271
+ minute = Number(m[6])
272
+ second = Number(m[7])
273
+ } else if ((m = RFC850_DATE_RE.exec(date))) {
274
+ weekday = HTTP_DATE_FULL_DAYS.indexOf(m[1])
275
+ day = Number(m[2])
276
+ month = HTTP_DATE_MONTHS[m[3]]
277
+ // RFC 6265 §5.1.1 two-digit year windowing: 70-99 → 19xx, 00-69 → 20xx.
278
+ year = Number(m[4])
279
+ year += year < 70 ? 2000 : 1900
280
+ hour = Number(m[5])
281
+ minute = Number(m[6])
282
+ second = Number(m[7])
283
+ } else if ((m = ASCTIME_DATE_RE.exec(date))) {
284
+ weekday = HTTP_DATE_DAYS.indexOf(m[1])
285
+ month = HTTP_DATE_MONTHS[m[2]]
286
+ day = Number(m[3].trim())
287
+ hour = Number(m[4])
288
+ minute = Number(m[5])
289
+ second = Number(m[6])
290
+ year = Number(m[7])
291
+ } else {
292
+ return undefined
293
+ }
294
+
295
+ if (day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) {
296
+ return undefined
23
297
  }
24
- return str && typeof str === 'string' ? cacheControlParser.parse(str) : null
298
+
299
+ const result = new Date(Date.UTC(year, month, day, hour, minute, second))
300
+ // Date.UTC normalizes out-of-range components (Feb 30 → Mar 2) and the named
301
+ // weekday must match the actual date — both are rejected by the same check
302
+ // upstream uses (getUTCDay comparison catches normalization only by luck;
303
+ // check the day-of-month explicitly as well).
304
+ return result.getUTCDate() === day && result.getUTCDay() === weekday ? result : undefined
25
305
  }
26
306
 
27
307
  export function isDisturbed(body) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxtedition/nxt-undici",
3
- "version": "7.4.2",
3
+ "version": "7.5.0",
4
4
  "license": "MIT",
5
5
  "author": "Robert Nagy <robert.nagy@boffins.se>",
6
6
  "main": "lib/index.js",
@@ -10,8 +10,8 @@
10
10
  ],
11
11
  "dependencies": {
12
12
  "@nxtedition/scheduler": "^4.1.1",
13
+ "@nxtedition/trace": "^1.0.0",
13
14
  "@nxtedition/undici": "^11.1.4",
14
- "cache-control-parser": "^2.2.0",
15
15
  "fast-querystring": "^1.1.2",
16
16
  "http-errors": "^2.0.1",
17
17
  "xxhash-wasm": "^1.1.0"