@johntalton/http-util 6.1.0 → 7.0.2

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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/src/body.js +11 -6
  4. package/src/defs.js +14 -5
  5. package/src/headers/accept-encoding.js +28 -5
  6. package/src/headers/accept-language.js +29 -5
  7. package/src/headers/accept.js +77 -32
  8. package/src/headers/cache-control.js +6 -3
  9. package/src/headers/client-hints.js +4 -3
  10. package/src/headers/conditional.js +18 -18
  11. package/src/headers/content-type.js +1 -1
  12. package/src/headers/link.js +9 -4
  13. package/src/headers/multipart.js +18 -17
  14. package/src/headers/range.js +4 -2
  15. package/src/headers/rate-limit.js +20 -4
  16. package/src/headers/server-timing.js +5 -3
  17. package/src/headers/util/kvp.js +2 -1
  18. package/src/headers/util/mime.js +17 -1
  19. package/src/headers/util/quote.js +1 -1
  20. package/src/headers/util/whitespace.js +1 -1
  21. package/src/headers/www-authenticate.js +35 -11
  22. package/src/response/2xx/accepted.js +2 -2
  23. package/src/response/2xx/bytes.js +2 -31
  24. package/src/response/2xx/created.js +8 -21
  25. package/src/response/2xx/json.js +6 -19
  26. package/src/response/2xx/no-content.js +4 -18
  27. package/src/response/2xx/partial-content.js +1 -28
  28. package/src/response/2xx/preflight.js +18 -25
  29. package/src/response/3xx/found.js +8 -6
  30. package/src/response/3xx/moved-permanently.js +8 -6
  31. package/src/response/3xx/multiple-choices.js +3 -3
  32. package/src/response/3xx/not-modified.js +14 -26
  33. package/src/response/3xx/permanent-redirect.js +7 -5
  34. package/src/response/3xx/see-other.js +8 -6
  35. package/src/response/3xx/temporary-redirect.js +7 -5
  36. package/src/response/4xx/bad-request.js +2 -3
  37. package/src/response/4xx/conflict.js +2 -2
  38. package/src/response/4xx/content-too-large.js +2 -2
  39. package/src/response/4xx/forbidden.js +3 -3
  40. package/src/response/4xx/gone.js +2 -2
  41. package/src/response/4xx/im-a-teapot.js +2 -2
  42. package/src/response/4xx/not-acceptable.js +2 -11
  43. package/src/response/4xx/not-allowed.js +6 -14
  44. package/src/response/4xx/payment-required.js +4 -4
  45. package/src/response/4xx/precondition-failed.js +4 -18
  46. package/src/response/4xx/range-not-satisfiable.js +4 -13
  47. package/src/response/4xx/timeout.js +3 -3
  48. package/src/response/4xx/too-many-requests.js +3 -20
  49. package/src/response/4xx/unauthorized.js +4 -4
  50. package/src/response/4xx/unprocessable.js +2 -2
  51. package/src/response/4xx/unsupported-media.js +16 -23
  52. package/src/response/5xx/error.js +2 -3
  53. package/src/response/5xx/insufficient-storage.js +2 -2
  54. package/src/response/5xx/not-implemented.js +2 -3
  55. package/src/response/5xx/unavailable.js +3 -20
  56. package/src/response/header-util.js +9 -5
  57. package/src/response/response.js +2 -2
  58. package/src/response/send-util.js +102 -30
package/README.md CHANGED
@@ -64,7 +64,7 @@ All responders take in a `stream` as well as a metadata object to hint on server
64
64
  - [`sendGone`](#responsegone)
65
65
  - [`sendImATeapot`](#)
66
66
  - [`sendInsufficientStorage`](#)
67
- - [`sendJSON_Encoded`](#responsejson) - Standard Ok response with encoding
67
+ - [`sendJSON`](#responsejson) - Standard Ok response with encoding
68
68
  - [`sendMovedPermanently`](#responsemovedpermanently)
69
69
  - [`sendMultipleChoices`](#)
70
70
  - [`sendNoContent`](#responsenocontent)
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@johntalton/http-util",
3
- "version": "6.1.0",
3
+ "version": "7.0.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
+ "sideEffects": false,
6
7
  "imports": {
7
8
  "#util": "./src/headers/util/index.js"
8
9
  },
package/src/body.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { Readable } from 'node:stream'
2
+
1
3
  import {
2
4
  CHARSET_UTF8,
3
5
  MIME_TYPE_MULTIPART_FORM_DATA,
@@ -8,7 +10,6 @@ import { Multipart } from './headers/multipart.js'
8
10
  export const BYTE_PER_K = 1024
9
11
  export const DEFAULT_BYTE_LIMIT = BYTE_PER_K * BYTE_PER_K //
10
12
 
11
- /** @import { Readable } from 'node:stream' */
12
13
  /** @import { ContentTypeItem } from './headers/content-type.js' */
13
14
 
14
15
  /**
@@ -24,7 +25,7 @@ export const DEFAULT_BYTE_LIMIT = BYTE_PER_K * BYTE_PER_K //
24
25
  * @property {number} duration
25
26
  * @property {ReadableStream} body
26
27
  * @property {ContentTypeItem|undefined} contentType
27
- * @property { (mimetype: string) => Promise<Blob> } blob
28
+ * @property { (mimetype?: string | undefined) => Promise<Blob> } blob
28
29
  * @property { () => Promise<ArrayBufferLike> } arrayBuffer
29
30
  * @property { () => Promise<Uint8Array> } bytes
30
31
  * @property { () => Promise<string> } text
@@ -38,6 +39,8 @@ export const DEFAULT_BYTE_LIMIT = BYTE_PER_K * BYTE_PER_K //
38
39
  * @returns {BodyFuture}
39
40
  */
40
41
  export function requestBody(stream, options) {
42
+ if(!(stream instanceof Readable)) { throw new Error('stream is not Readable') }
43
+
41
44
  const signal = options?.signal
42
45
  const byteLimit = options?.byteLimit ?? DEFAULT_BYTE_LIMIT
43
46
  const contentLength = options?.contentLength
@@ -93,7 +96,7 @@ export function requestBody(stream, options) {
93
96
 
94
97
  const listener = () => {
95
98
  stats.closed = true
96
- controller.error(new Error('Abort Signal'))
99
+ controller.error(new Error(`Abort Signal (${signal?.reason})`))
97
100
  }
98
101
 
99
102
  signal?.addEventListener('abort', listener, { once: true })
@@ -101,7 +104,7 @@ export function requestBody(stream, options) {
101
104
  stream.on('data', chunk => {
102
105
  if(signal?.aborted) {
103
106
  console.log('body reader aborted')
104
- controller.error(new Error('Chunk read Abort Signal Timed out'))
107
+ controller.error(new Error(`Chunk read Abort Signal (${signal.reason})`))
105
108
  stats.closed = true
106
109
  return
107
110
  }
@@ -114,8 +117,9 @@ export function requestBody(stream, options) {
114
117
 
115
118
  // chunk is a node Buffer (which is a TypedArray)
116
119
  if(!ArrayBuffer.isView(chunk)) {
117
- controller.error('invalid chunk type')
120
+ controller.error(new Error('invalid chunk type'))
118
121
  stats.closed = true
122
+ return
119
123
  }
120
124
 
121
125
  stats.byteLength += chunk.byteLength
@@ -157,6 +161,7 @@ export function requestBody(stream, options) {
157
161
 
158
162
  cancel(reason) {
159
163
  console.log('body reader canceled', reason)
164
+ // super.cancel(reason)
160
165
  }
161
166
  }
162
167
 
@@ -190,7 +195,7 @@ export function requestBody(stream, options) {
190
195
  get body() { return makeReader() },
191
196
  get contentType() { return contentType },
192
197
 
193
- blob: (/** @type {string | undefined} */ mimetype) => wrap(reader => bodyBlob(reader, mimetype ?? contentType?.mimetype)),
198
+ blob: (/** @type {string | undefined} */ mimetype = undefined) => wrap(reader => bodyBlob(reader, mimetype ?? contentType?.mimetype)),
194
199
  arrayBuffer: () => wrap(reader => bodyArrayBuffer(reader)),
195
200
  bytes: () => wrap(reader => bodyUint8Array(reader)),
196
201
  text: () => wrap(reader => bodyText(reader, charset)),
package/src/defs.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** @import { Readable } from 'node:stream' */
2
+ /** @import { ReadableStream } from 'node:stream/web' */
1
3
 
2
4
  export const HTTP_HEADER_ORIGIN = 'origin'
3
5
  export const HTTP_HEADER_USER_AGENT = 'user-agent'
@@ -26,9 +28,14 @@ export const RANGE_UNITS_BYTES = 'bytes'
26
28
  /** @type {'none'} */
27
29
  export const RANGE_UNITS_NONE = 'none'
28
30
 
31
+ /** @description joiner used for concatenating multiple of the same headers into one */
32
+ export const COMMON_LIST_HEADER_JOINER_COMMA = ', '
33
+
34
+ /** @description joiner used for concatenating multiple values of single header */
35
+ export const COMMON_LIST_VALUE_JOINER_COMMA = ','
29
36
 
30
37
  /** @import { TimingsInfo } from './headers/server-timing.js' */
31
- /** @import { EtagItem, IMFFixDateInput, } from './headers/conditional.js' */
38
+ /** @import { EtagItem, IMFFixDateInput } from './headers/conditional.js' */
32
39
  /** @import { CacheControlOptions } from './headers/cache-control.js' */
33
40
  /** @import { ContentRangeDirective } from './headers/content-range.js' */
34
41
  /** @import { RateLimitPolicyInfo, RateLimitInfo } from './headers/rate-limit.js' */
@@ -44,7 +51,7 @@ export const RANGE_UNITS_NONE = 'none'
44
51
  * @property {Array<TimingsInfo>} performance
45
52
  * @property {string|undefined} servername
46
53
  * @property {string|undefined} origin
47
- * @property {Array<[ CustomHeaderKey, string ]>|undefined} [customHeaders]
54
+ * @property {Array<[ CustomHeaderKey, string | Array<string> ]>|undefined} [customHeaders]
48
55
  */
49
56
 
50
57
  /**
@@ -53,7 +60,9 @@ export const RANGE_UNITS_NONE = 'none'
53
60
  * @property {boolean} [bom]
54
61
  */
55
62
 
56
- /** @typedef {ArrayBufferLike|ArrayBufferView|ReadableStream|string} SendBody */
63
+ /** @typedef {NodeJS.TypedArray<ArrayBuffer>} TypedArray */
64
+ /** @typedef {ArrayBuffer | TypedArray | string} SendBodyTypes */
65
+ /** @typedef { SendBodyTypes | ReadableStream<SendBodyTypes> | Readable } SendBody */
57
66
 
58
67
  /**
59
68
  * @typedef {Object} SendContent
@@ -70,8 +79,8 @@ export const RANGE_UNITS_NONE = 'none'
70
79
  /**
71
80
  * @typedef {Object} SendInfo
72
81
  * @property {Array<string>} supportedMethods
73
- * @property {Array<string>|string} supportedTypes
74
- * @property {Array<string>|string} acceptableMediaType
82
+ * @property {Array<string>|string} supportedTypes for content negotiation failures
83
+ * @property {Array<string>|string} acceptableMediaType for incoming post/put unsupported types
75
84
  * @property {AcceptRangeUnits|undefined} acceptRanges
76
85
  * @property {Array<string>|undefined} supportedQueryTypes
77
86
  * @property {RateLimitInfo} limitInfo
@@ -18,6 +18,8 @@ export class AcceptEncoding {
18
18
  }
19
19
 
20
20
  /**
21
+ * @deprecated
22
+ * @see {@link AcceptEncoding.selectItemFrom}
21
23
  * @param {string|undefined} acceptEncodingHeader
22
24
  * @param {Array<string>} supportedTypes
23
25
  */
@@ -27,24 +29,45 @@ export class AcceptEncoding {
27
29
  }
28
30
 
29
31
  /**
30
- * @param {Array<AcceptStyleItem>} acceptEncodings
31
- * @param {Array<string>} supportedTypes
32
+ * @param {Array<AcceptStyleItem>} acceptEncodings (descending quality order)
33
+ * @param {Array<string>} supportedTypes (descending preferred order)
34
+ * @returns {AcceptStyleItem | undefined}
32
35
  */
33
- static selectFrom(acceptEncodings, supportedTypes) {
36
+ static selectItemFrom(acceptEncodings, supportedTypes) {
37
+ if(acceptEncodings === undefined) { return undefined }
38
+ if(!Array.isArray(acceptEncodings)) { return undefined }
39
+ if(acceptEncodings.length === 0) { return undefined }
40
+
34
41
  if(supportedTypes === undefined) { return undefined }
42
+ if(!Array.isArray(supportedTypes)) { return undefined }
43
+ if(supportedTypes.length === 0) { return undefined }
35
44
 
36
45
  for(const acceptEncoding of acceptEncodings) {
37
46
  const { name } = acceptEncoding
38
47
  if(supportedTypes.includes(name)) {
39
- return name
48
+ return acceptEncoding
40
49
  }
41
50
  }
42
51
 
43
52
  //
44
53
  if(acceptEncodings.some(item => item.name === ENCODING_ANY)) {
45
- return supportedTypes.at(0)
54
+ const [ name ] = supportedTypes
55
+ if(name === undefined) { return undefined }
56
+ return { name }
46
57
  }
47
58
 
48
59
  return undefined
49
60
  }
61
+
62
+ /**
63
+ * @deprecated
64
+ * @see {@link AcceptEncoding.selectItemFrom}
65
+ * @param {Array<AcceptStyleItem>} acceptEncodings
66
+ * @param {Array<string>} supportedTypes
67
+ * @returns {string | undefined}
68
+ */
69
+ static selectFrom(acceptEncodings, supportedTypes) {
70
+ const item = AcceptEncoding.selectItemFrom(acceptEncodings, supportedTypes)
71
+ return item?.name
72
+ }
50
73
  }
@@ -19,6 +19,8 @@ export class AcceptLanguage {
19
19
  }
20
20
 
21
21
  /**
22
+ * @deprecated
23
+ * @see {@link AcceptLanguage.selectItemFrom}
22
24
  * @param {string|undefined} acceptLanguageHeader
23
25
  * @param {Array<string>} supportedTypes
24
26
  */
@@ -28,24 +30,46 @@ export class AcceptLanguage {
28
30
  }
29
31
 
30
32
  /**
31
- * @param {Array<AcceptStyleItem>} acceptLanguages
32
- * @param {Array<string>} supportedTypes
33
+ * @param {Array<AcceptStyleItem>} acceptLanguages (descending quality order)
34
+ * @param {Array<string>} supportedTypes (descending preferred order)
35
+ * @returns {AcceptStyleItem | undefined}
33
36
  */
34
- static selectFrom(acceptLanguages, supportedTypes) {
37
+ static selectItemFrom(acceptLanguages, supportedTypes) {
38
+ if(acceptLanguages === undefined) { return undefined }
39
+ if(!Array.isArray(acceptLanguages)) { return undefined }
40
+ if(acceptLanguages.length === 0) { return undefined }
41
+
35
42
  if(supportedTypes === undefined) { return undefined }
43
+ if(!Array.isArray(supportedTypes)) { return undefined }
44
+ if(supportedTypes.length === 0) { return undefined }
36
45
 
46
+ // this assume acceptLangues is quality sorted descending order
37
47
  for(const acceptLanguage of acceptLanguages) {
38
48
  const { name } = acceptLanguage
39
49
  if(supportedTypes.includes(name)) {
40
- return name
50
+ return acceptLanguage
41
51
  }
42
52
  }
43
53
 
44
54
  //
45
55
  if(acceptLanguages.some(item => item.name === LANGUAGE_ANY)) {
46
- return supportedTypes.at(0)
56
+ const name = supportedTypes.at(0)
57
+ if(name === undefined) { return undefined }
58
+ return { name }
47
59
  }
48
60
 
49
61
  return undefined
50
62
  }
63
+
64
+ /**
65
+ * @deprecated
66
+ * @see {@link AcceptLanguage.selectItemFrom}
67
+ * @param {Array<AcceptStyleItem>} acceptLanguages
68
+ * @param {Array<string>} supportedTypes
69
+ * @returns {string | undefined}
70
+ */
71
+ static selectFrom(acceptLanguages, supportedTypes) {
72
+ const item = AcceptLanguage.selectItemFrom(acceptLanguages, supportedTypes)
73
+ return item?.name
74
+ }
51
75
  }
@@ -1,5 +1,5 @@
1
1
  import { parseAcceptStyleHeader } from './util/accept-util.js'
2
- import { MIME_ANY, Mime } from './util/mime.js'
2
+ import { MIME_ANY, MIME_SEPARATOR, Mime } from './util/mime.js'
3
3
 
4
4
  /** @import { AcceptStyleItem } from './util/accept-util.js' */
5
5
  /** @import { MimeItem } from './util/mime.js' */
@@ -11,7 +11,30 @@ export const WELL_KNOWN = new Map([
11
11
  [ 'application/json', [ { name: 'application/json', quality: 1 } ] ]
12
12
  ])
13
13
 
14
+ export const UNSPECIFIED_QUALITY = 1
15
+
14
16
  export class Accept {
17
+ /**
18
+ * Descending order based on quality and higher specificity.
19
+ *
20
+ * Returns negative is the first item (a) is of higher quality then second (b).
21
+ * @param {AcceptItem} a
22
+ * @param {AcceptItem} b
23
+ */
24
+ static compare(a, b) {
25
+ if(a.quality === b.quality) {
26
+ // prefer things with less ANY
27
+ const specificityA = (a.type === MIME_ANY ? 1 : 0) + (a.subtype === MIME_ANY ? 1 : 0)
28
+ const specificityB = (b.type === MIME_ANY ? 1 : 0) + (b.subtype === MIME_ANY ? 1 : 0)
29
+ return Math.sign(specificityA - specificityB)
30
+ }
31
+
32
+ // B - A descending order
33
+ const qualityB = b.quality ?? UNSPECIFIED_QUALITY
34
+ const qualityA = a.quality ?? UNSPECIFIED_QUALITY
35
+ return Math.sign(qualityB - qualityA)
36
+ }
37
+
15
38
  /**
16
39
  * @param {string|undefined} acceptHeader
17
40
  * @returns {Array<AcceptItem>}
@@ -31,22 +54,12 @@ export class Accept {
31
54
  }
32
55
  })
33
56
  .filter(entry => entry !== undefined)
34
- .sort((entryA, entryB) => {
35
- if(entryA.quality === entryB.quality) {
36
- // prefer things with less ANY
37
- const specificityA = (entryA.type === MIME_ANY ? 1 : 0) + (entryA.subtype === MIME_ANY ? 1 : 0)
38
- const specificityB = (entryB.type === MIME_ANY ? 1 : 0) + (entryB.subtype === MIME_ANY ? 1 : 0)
39
- return specificityA - specificityB
40
- }
41
-
42
- // B - A descending order
43
- const qualityB = entryB.quality ?? 0
44
- const qualityA = entryA.quality ?? 0
45
- return qualityB - qualityA
46
- })
57
+ .sort(Accept.compare)
47
58
  }
48
59
 
49
60
  /**
61
+ * @deprecated
62
+ * @see {@link Accept.selectItemFrom}
50
63
  * @param {string|undefined} acceptHeader
51
64
  * @param {Array<string>} supportedTypes
52
65
  */
@@ -58,29 +71,61 @@ export class Accept {
58
71
  /**
59
72
  * @param {Array<AcceptItem>} accepts
60
73
  * @param {Array<string>} supportedTypes
74
+ * @returns {AcceptItem | undefined}
61
75
  */
62
- static selectFrom(accepts, supportedTypes) {
63
- const bests = accepts.map(accept => {
64
- const { type, subtype, quality } = accept
65
- const st = supportedTypes.filter(supportedType => {
66
- const supportedMime = Mime.parse(supportedType)
67
- if(supportedMime === undefined) { return false }
68
- return ((supportedMime.type === type || type === MIME_ANY) && (supportedMime.subtype === subtype || subtype === MIME_ANY))
69
- })
76
+ static selectItemFrom(accepts, supportedTypes) {
77
+ if(accepts === undefined) { return undefined }
78
+ if(!Array.isArray(accepts)) { return undefined }
79
+ if(accepts.length === 0) { return undefined }
80
+
81
+ if(supportedTypes === undefined) { return undefined }
82
+ if(!Array.isArray(supportedTypes)) { return undefined }
83
+ if(supportedTypes.length === 0) { return undefined }
84
+
85
+ const supportedMimeTypes = supportedTypes
86
+ .map(Mime.parse)
87
+ .filter(m => m !== undefined)
88
+
89
+ // todo if supportedMimeType has ANY show warning?
70
90
 
91
+ if(supportedMimeTypes.length === 0) { return undefined }
92
+
93
+ const matches = accepts.map(accept => {
94
+ const matchSupportedMimeTypes = supportedMimeTypes.filter(mt => Mime.matches(accept, mt))
95
+ const best = matchSupportedMimeTypes.at(0)
96
+ if(best === undefined) { return undefined }
97
+
98
+ // resolve mime type and subtype
99
+ const type = best.type === MIME_ANY ? accept.type : best.type
100
+ const subtype = best.subtype === MIME_ANY ? accept.subtype : best.subtype
101
+
102
+ // preserve name and parameters of source
71
103
  return {
72
- supportedTypes: st,
73
- quality
104
+ ...accept,
105
+ mimetype: `${type}${MIME_SEPARATOR.SUBTYPE}${subtype}`,
106
+ type,
107
+ subtype
74
108
  }
75
109
  })
76
- .filter(best => best.supportedTypes.length > 0)
110
+ .filter(m => m !== undefined)
77
111
 
78
- if(bests.length === 0) { return undefined }
79
- const [ first ] = bests
80
- if(first === undefined) { return undefined }
81
- const [ firstSt ] = first.supportedTypes
82
- return firstSt
83
- }
84
- }
112
+ if(matches.length === 0) { return undefined }
113
+
114
+ // sort is in-place
115
+ matches.sort(Accept.compare)
85
116
 
117
+ return matches.at(0)
118
+ }
86
119
 
120
+ /**
121
+ * @deprecated
122
+ * @see {@link Accept.selectItemFrom}
123
+ * @param {Array<AcceptItem>} accepts
124
+ * @param {Array<string>} supportedTypes
125
+ * @returns {string | undefined}
126
+ */
127
+ static selectFrom(accepts, supportedTypes) {
128
+ const item = Accept.selectItemFrom(accepts, supportedTypes)
129
+ return item?.mimetype
130
+ }
131
+ }
@@ -1,3 +1,5 @@
1
+ import { COMMON_LIST_HEADER_JOINER_COMMA } from "../defs.js"
2
+
1
3
  /** @typedef {'no-cache'|'no-store'|'no-transform'|'must-revalidate'|'immutable'|'must-understand'} Directives */
2
4
 
3
5
  /**
@@ -13,9 +15,10 @@
13
15
  export class CacheControl {
14
16
  /**
15
17
  * @param {CacheControlOptions|undefined} options
16
- * @returns {string|undefined}
18
+ * @param {boolean} [asArray = false]
19
+ * @returns {Array<string>|string|undefined}
17
20
  */
18
- static encode(options) {
21
+ static encode(options, asArray = false) {
19
22
  if(options === undefined) { return undefined }
20
23
 
21
24
  const {
@@ -51,6 +54,6 @@ export class CacheControl {
51
54
  //
52
55
  if(result.length === 0) { return undefined }
53
56
 
54
- return result.join(', ')
57
+ return asArray ? result : result.join(COMMON_LIST_HEADER_JOINER_COMMA)
55
58
  }
56
59
  }
@@ -1,4 +1,4 @@
1
-
1
+ import { COMMON_LIST_HEADER_JOINER_COMMA } from "../defs.js"
2
2
 
3
3
  export const CLIENT_HINT_USER_AGENT = 'Sec-CH-UA'
4
4
  export const CLIENT_HINT_ARCHITECTURE = 'Sec-CH-UA-Arch'
@@ -69,8 +69,9 @@ export class ClientHints {
69
69
 
70
70
  /**
71
71
  * @param {Array<String>} hints
72
+ * @param {boolean} [asArray = false]
72
73
  */
73
- static encode(hints) {
74
+ static encode(hints, asArray = false) {
74
75
  if(hints === undefined) { return undefined }
75
76
  if(!Array.isArray(hints)) { return undefined }
76
77
  if(hints.length === 0) { return undefined }
@@ -80,7 +81,7 @@ export class ClientHints {
80
81
 
81
82
  if(remaining.length === 0) { return undefined }
82
83
 
83
- return remaining.join(', ')
84
+ return asArray ? remaining : remaining.join(COMMON_LIST_HEADER_JOINER_COMMA)
84
85
  }
85
86
  }
86
87
 
@@ -102,7 +102,7 @@ export class ETag {
102
102
  static isValid(etag) {
103
103
  if(etag === undefined) { return false }
104
104
 
105
- // %x21 / %x23-7E and %x80-FF
105
+ // %x21 / %x23-7E and %x80-FF
106
106
  for(const c of etag) {
107
107
  if(c.charCodeAt(0) < 0x21) { return false }
108
108
  if(c.charCodeAt(0) > 0xFF) { return false }
@@ -143,7 +143,7 @@ export class ETag {
143
143
 
144
144
  const rawEtag = raw.trim()
145
145
  const weak = rawEtag.startsWith(CONDITION_ETAG_WEAK_PREFIX)
146
- const quotedEtag = weak ? rawEtag.substring(CONDITION_ETAG_WEAK_PREFIX.length) : rawEtag
146
+ const quotedEtag = weak ? rawEtag.slice(CONDITION_ETAG_WEAK_PREFIX.length) : rawEtag
147
147
 
148
148
  if(quotedEtag === CONDITION_ETAG_ANY) { return ANY_ETAG_ITEM }
149
149
 
@@ -350,18 +350,18 @@ export class Conditional {
350
350
 
351
351
  //
352
352
  const spaces = [
353
- matchHeader.substring(4, 5),
354
- matchHeader.substring(7, 8),
355
- matchHeader.substring(11, 12),
356
- matchHeader.substring(16, 17),
357
- matchHeader.substring(25, 26)
353
+ matchHeader.slice(4, 5),
354
+ matchHeader.slice(7, 8),
355
+ matchHeader.slice(11, 12),
356
+ matchHeader.slice(16, 17),
357
+ matchHeader.slice(25, 26)
358
358
  ]
359
- const comma = matchHeader.substring(3, 4)
359
+ const comma = matchHeader.slice(3, 4)
360
360
  const timeSeparators = [
361
- matchHeader.substring(19, 20),
362
- matchHeader.substring(22, 23)
361
+ matchHeader.slice(19, 20),
362
+ matchHeader.slice(22, 23)
363
363
  ]
364
- const gmt = matchHeader.substring(26)
364
+ const gmt = matchHeader.slice(26)
365
365
 
366
366
  //
367
367
  if(comma !== DATE_SEPARATOR) { return undefined }
@@ -374,13 +374,13 @@ export class Conditional {
374
374
  }
375
375
 
376
376
  //
377
- const dayName = matchHeader.substring(0, 3)
378
- const day = Number.parseInt(matchHeader.substring(5, 7))
379
- const month = matchHeader.substring(8, 11)
380
- const year = Number.parseInt(matchHeader.substring(12, 16))
381
- const hour = Number.parseInt(matchHeader.substring(17, 19))
382
- const minute = Number.parseInt(matchHeader.substring(20, 22))
383
- const second = Number.parseInt(matchHeader.substring(23, 25))
377
+ const dayName = matchHeader.slice(0, 3)
378
+ const day = Number.parseInt(matchHeader.slice(5, 7))
379
+ const month = matchHeader.slice(8, 11)
380
+ const year = Number.parseInt(matchHeader.slice(12, 16))
381
+ const hour = Number.parseInt(matchHeader.slice(17, 19))
382
+ const minute = Number.parseInt(matchHeader.slice(20, 22))
383
+ const second = Number.parseInt(matchHeader.slice(23, 25))
384
384
 
385
385
  //
386
386
  if(!DATE_DAYS.includes(dayName)) { return undefined }
@@ -16,7 +16,7 @@ export const MIME_TYPE_MESSAGE_HTTP = 'message/http'
16
16
 
17
17
  export const KNOWN_CONTENT_TYPES = [
18
18
  'application', 'audio', 'image', 'message',
19
- 'multipart','text', 'video'
19
+ 'multipart', 'text', 'video'
20
20
  ]
21
21
 
22
22
  export const TYPE_X_TOKEN_PREFIX = 'X-'
@@ -1,3 +1,5 @@
1
+ import { COMMON_LIST_HEADER_JOINER_COMMA } from "../defs.js"
2
+
1
3
  /**
2
4
  * @typedef {Object} LinkItem
3
5
  * @property {URL|string} url
@@ -9,7 +11,7 @@ export class Link {
9
11
  /**
10
12
  * @param {LinkItem} link
11
13
  */
12
- static *#encode(link) {
14
+ static *#encode(link) {
13
15
  const encodedUri = (link.url instanceof URL) ? link.url : encodeURI(link.url)
14
16
 
15
17
  yield `<${encodedUri}>`
@@ -22,13 +24,16 @@ export class Link {
22
24
 
23
25
  /**
24
26
  * @param {Array<LinkItem>|LinkItem|undefined} links
27
+ * @param {boolean} [asArray = false]
25
28
  */
26
- static encode(links) {
29
+ static encode(links, asArray = false) {
27
30
  if(links === undefined) { return undefined }
28
31
  const linkAry = Array.isArray(links) ? links : [ links ]
29
32
  if(linkAry.length === 0) { return undefined }
30
- return linkAry
33
+
34
+ const ary = linkAry
31
35
  .map(link => [ ...Link.#encode(link) ].join('; '))
32
- .join(', ')
36
+
37
+ return asArray ? ary : ary.join(COMMON_LIST_HEADER_JOINER_COMMA)
33
38
  }
34
39
  }
@@ -1,9 +1,8 @@
1
1
  import { ReadableStream } from 'node:stream/web'
2
2
 
3
- import { isQuoted, stripQuotes } from '../headers/util/quote.js'
4
3
  import { ContentDisposition } from './content-disposition.js'
5
4
  import { ContentRange } from './content-range.js'
6
- import { ContentType } from './content-type.js'
5
+ // import { ContentType } from './content-type.js'
7
6
 
8
7
  /** @import { ContentRangeDirective } from './content-range.js' */
9
8
  /** @import { SendBody } from '../defs.js' */
@@ -65,11 +64,6 @@ export class Multipart {
65
64
 
66
65
  const lines = text.split(MULTIPART_SEPARATOR)
67
66
 
68
- if(lines.length === 0) {
69
- // missing body?
70
- return formData
71
- }
72
-
73
67
  const boundaryBegin = `${BOUNDARY_MARK}${boundary}`
74
68
  const boundaryEnd = `${BOUNDARY_MARK}${boundary}${BOUNDARY_MARK}`
75
69
 
@@ -98,7 +92,7 @@ export class Multipart {
98
92
  const name = rawName?.toLowerCase()
99
93
  // console.log('header', name, value)
100
94
  if(name === MULTIPART_HEADER.CONTENT_TYPE) {
101
- const contentType = ContentType.parse(value)
95
+ //const contentType = ContentType.parse(value)
102
96
  //console.log({ contentType })
103
97
  }
104
98
  else if(name === MULTIPART_HEADER.CONTENT_DISPOSITION) {
@@ -107,7 +101,10 @@ export class Multipart {
107
101
  throw new Error('disposition not form-data')
108
102
  }
109
103
 
110
- partName = isQuoted(disposition.name) ? stripQuotes(disposition.name) : disposition.name
104
+ partName = disposition.name
105
+ }
106
+ else if(name === MULTIPART_HEADER.CONTENT_RANGE) {
107
+ // todo
111
108
  }
112
109
  else {
113
110
  // unsupported part header - ignore
@@ -131,10 +128,9 @@ export class Multipart {
131
128
  }
132
129
  state = MULTIPART_STATE.HEADERS
133
130
  }
134
- else {
135
- throw new Error('unknown state')
136
- }
137
-
131
+ // else {
132
+ // throw new Error('unknown state')
133
+ // }
138
134
  }
139
135
 
140
136
  return formData
@@ -145,7 +141,7 @@ export class Multipart {
145
141
  * @param {Array<MultipartBytePart>} parts
146
142
  * @param {number|undefined} contentLength
147
143
  * @param {string} boundary
148
- * @returns {ReadableStream<Uint8Array>}
144
+ * @returns {ReadableStream<Uint8Array<ArrayBuffer>>}
149
145
  */
150
146
  static encode_Bytes(contentType, parts, contentLength, boundary) {
151
147
  const boundaryBegin = `${BOUNDARY_MARK}${boundary}`
@@ -166,16 +162,21 @@ export class Multipart {
166
162
  if(part.obj instanceof ReadableStream) {
167
163
  // biome-ignore lint/performance/noAwaitInLoops: readable
168
164
  for await (const chunk of part.obj) {
169
- if(chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk)) {
165
+ if(chunk instanceof ArrayBuffer) {
166
+ controller.enqueue(new Uint8Array(chunk))
167
+ }
168
+ else if(ArrayBuffer.isView(chunk)) {
170
169
  controller.enqueue(chunk)
171
170
  }
172
171
  else if(typeof chunk === 'string'){
173
172
  controller.enqueue(encoder.encode(chunk))
174
173
  }
175
- else {
176
- // console.log('chunk type', typeof chunk)
174
+ else if(typeof chunk === 'number') {
177
175
  controller.enqueue(Uint8Array.from([ chunk ]))
178
176
  }
177
+ else {
178
+ controller.error(new Error('unknown stream chunk type'))
179
+ }
179
180
  }
180
181
  }
181
182
  else if(part.obj instanceof ArrayBuffer) {