@johntalton/http-util 7.0.1 → 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 (47) hide show
  1. package/package.json +1 -1
  2. package/src/body.js +11 -6
  3. package/src/defs.js +13 -4
  4. package/src/headers/accept-encoding.js +28 -5
  5. package/src/headers/accept-language.js +29 -5
  6. package/src/headers/accept.js +77 -32
  7. package/src/headers/cache-control.js +6 -3
  8. package/src/headers/client-hints.js +4 -3
  9. package/src/headers/conditional.js +17 -17
  10. package/src/headers/content-type.js +1 -1
  11. package/src/headers/link.js +8 -3
  12. package/src/headers/multipart.js +14 -6
  13. package/src/headers/range.js +2 -2
  14. package/src/headers/rate-limit.js +19 -3
  15. package/src/headers/server-timing.js +5 -3
  16. package/src/headers/util/kvp.js +2 -1
  17. package/src/headers/util/mime.js +16 -0
  18. package/src/headers/util/quote.js +1 -1
  19. package/src/headers/www-authenticate.js +35 -10
  20. package/src/response/2xx/accepted.js +2 -2
  21. package/src/response/2xx/created.js +3 -3
  22. package/src/response/2xx/no-content.js +3 -3
  23. package/src/response/2xx/preflight.js +9 -9
  24. package/src/response/3xx/found.js +3 -3
  25. package/src/response/3xx/moved-permanently.js +3 -3
  26. package/src/response/3xx/multiple-choices.js +3 -3
  27. package/src/response/3xx/not-modified.js +12 -5
  28. package/src/response/3xx/permanent-redirect.js +3 -3
  29. package/src/response/3xx/see-other.js +3 -3
  30. package/src/response/3xx/temporary-redirect.js +3 -3
  31. package/src/response/4xx/bad-request.js +1 -2
  32. package/src/response/4xx/conflict.js +2 -2
  33. package/src/response/4xx/content-too-large.js +2 -2
  34. package/src/response/4xx/forbidden.js +3 -3
  35. package/src/response/4xx/gone.js +2 -2
  36. package/src/response/4xx/im-a-teapot.js +2 -2
  37. package/src/response/4xx/not-allowed.js +5 -4
  38. package/src/response/4xx/payment-required.js +3 -3
  39. package/src/response/4xx/precondition-failed.js +3 -3
  40. package/src/response/4xx/range-not-satisfiable.js +3 -3
  41. package/src/response/4xx/timeout.js +3 -3
  42. package/src/response/4xx/too-many-requests.js +2 -5
  43. package/src/response/4xx/unauthorized.js +4 -4
  44. package/src/response/4xx/unprocessable.js +2 -2
  45. package/src/response/4xx/unsupported-media.js +15 -9
  46. package/src/response/header-util.js +8 -4
  47. package/src/response/send-util.js +77 -24
@@ -1,7 +1,7 @@
1
1
  import http2 from 'node:http2'
2
2
 
3
3
  import { Challenge } from '../../headers/www-authenticate.js'
4
- import { send } from '../send-util.js'
4
+ import { send_no_body } from '../send-util.js'
5
5
 
6
6
  /** @import { ServerHttp2Stream } from 'node:http2' */
7
7
  /** @import { Metadata } from '../../defs.js' */
@@ -19,7 +19,7 @@ const { HTTP_STATUS_UNAUTHORIZED } = http2.constants
19
19
  * @param {Metadata} meta
20
20
  */
21
21
  export function sendUnauthorized(stream, challenge, meta) {
22
- send(stream, HTTP_STATUS_UNAUTHORIZED, {
23
- [HTTP2_HEADER_WWW_AUTHENTICATE]: challenge?.map(Challenge.encode), // todo stringify ?
24
- }, [ HTTP2_HEADER_WWW_AUTHENTICATE ], undefined, undefined, meta)
22
+ send_no_body(stream, HTTP_STATUS_UNAUTHORIZED, {
23
+ [HTTP2_HEADER_WWW_AUTHENTICATE]: Challenge.encode(challenge),
24
+ }, [ HTTP2_HEADER_WWW_AUTHENTICATE ], meta)
25
25
  }
@@ -1,6 +1,6 @@
1
1
  import http2 from 'node:http2'
2
2
 
3
- import { send } from '../send-util.js'
3
+ import { send_no_body } from '../send-util.js'
4
4
 
5
5
  /** @import { ServerHttp2Stream } from 'node:http2' */
6
6
  /** @import { Metadata } from '../../defs.js' */
@@ -12,5 +12,5 @@ const { HTTP_STATUS_UNPROCESSABLE_ENTITY } = http2.constants
12
12
  * @param {Metadata} meta
13
13
  */
14
14
  export function sendUnprocessable(stream, meta) {
15
- send(stream, HTTP_STATUS_UNPROCESSABLE_ENTITY, {}, [], undefined, undefined, meta)
15
+ send_no_body(stream, HTTP_STATUS_UNPROCESSABLE_ENTITY, {}, [], meta)
16
16
  }
@@ -1,7 +1,12 @@
1
1
  import http2 from 'node:http2'
2
2
 
3
- import { HTTP_HEADER_ACCEPT_PATCH, HTTP_HEADER_ACCEPT_POST, HTTP_HEADER_ACCEPT_QUERY } from '../../defs.js'
4
- import { send } from '../send-util.js'
3
+ import {
4
+ COMMON_LIST_VALUE_JOINER_COMMA,
5
+ HTTP_HEADER_ACCEPT_PATCH,
6
+ HTTP_HEADER_ACCEPT_POST,
7
+ HTTP_HEADER_ACCEPT_QUERY
8
+ } from '../../defs.js'
9
+ import { send_no_body } from '../send-util.js'
5
10
 
6
11
  /** @import { ServerHttp2Stream } from 'node:http2' */
7
12
  /** @import { SendInfo, Metadata } from '../../defs.js' */
@@ -21,16 +26,17 @@ export function sendUnsupportedMediaType(stream, info, meta) {
21
26
  acceptableMediaType
22
27
  } = info
23
28
 
24
- const supportsQuery = supportedQueryTypes !== undefined && supportedQueryTypes.length > 0
25
- const exposedHeaders = supportsQuery ? [ HTTP_HEADER_ACCEPT_QUERY, HTTP_HEADER_ACCEPT_POST ] : [ HTTP_HEADER_ACCEPT_POST ]
26
-
27
29
  const method = HTTP2_METHOD_POST // todo pass in as parameter or split acceptable to post and patch types
28
30
  const acceptable = Array.isArray(acceptableMediaType) ? acceptableMediaType : [ acceptableMediaType ] // todo undefined creates array of one item
29
31
  const acceptHeader = (method === HTTP2_METHOD_POST) ? HTTP_HEADER_ACCEPT_POST : HTTP_HEADER_ACCEPT_PATCH
30
- const acceptValue = ((method === HTTP2_METHOD_POST) || (method === HTTP2_METHOD_PATCH)) ? acceptable.join(',') : undefined
32
+ const acceptValue = ((method === HTTP2_METHOD_POST) || (method === HTTP2_METHOD_PATCH)) ? acceptable.join(COMMON_LIST_VALUE_JOINER_COMMA) : undefined
33
+
34
+ const supportsQuery = supportedQueryTypes !== undefined && supportedQueryTypes.length > 0
35
+ const exposedHeaders = supportsQuery ? [ HTTP_HEADER_ACCEPT_QUERY, acceptHeader ] : [ acceptHeader ]
36
+
31
37
 
32
- send(stream, HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
38
+ send_no_body(stream, HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, {
33
39
  [acceptHeader]: acceptValue,
34
- [HTTP_HEADER_ACCEPT_QUERY]: supportedQueryTypes?.join(',')
35
- }, exposedHeaders, undefined, undefined, meta)
40
+ [HTTP_HEADER_ACCEPT_QUERY]: supportedQueryTypes?.join(COMMON_LIST_VALUE_JOINER_COMMA)
41
+ }, exposedHeaders, meta)
36
42
  }
@@ -1,5 +1,7 @@
1
1
  import http2 from 'node:http2'
2
2
 
3
+ import { COMMON_LIST_VALUE_JOINER_COMMA } from '../defs.js'
4
+
3
5
  import {
4
6
  HTTP_HEADER_SERVER_TIMING,
5
7
  HTTP_HEADER_TIMING_ALLOW_ORIGIN,
@@ -15,8 +17,8 @@ const {
15
17
  HTTP2_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
16
18
  HTTP2_HEADER_CONTENT_TYPE,
17
19
  HTTP2_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS,
18
-
19
- HTTP2_HEADER_ETAG
20
+ HTTP2_HEADER_ETAG,
21
+ // HTTP2_HEADER_STRICT_TRANSPORT_SECURITY
20
22
  } = http2.constants
21
23
 
22
24
  /**
@@ -30,8 +32,9 @@ export function coreHeaders(status, contentType, exposedHeaders, meta) {
30
32
  const exposed = [ HTTP2_HEADER_ETAG, HTTP2_HEADER_SERVER, ...exposedHeaders ] // todo include lastModified
31
33
 
32
34
  return {
35
+ // todo [HTTP2_HEADER_STRICT_TRANSPORT_SECURITY]: StrictTransportSecurity.encode(meta.hsts)
33
36
  [HTTP2_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN]: meta.origin,
34
- [HTTP2_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS]: exposed.join(','),
37
+ [HTTP2_HEADER_ACCESS_CONTROL_EXPOSE_HEADERS]: exposed.join(COMMON_LIST_VALUE_JOINER_COMMA),
35
38
  // Access-Control-Allow-Credentials // for non-preflight
36
39
  [HTTP2_HEADER_STATUS]: status,
37
40
  [HTTP2_HEADER_CONTENT_TYPE]: contentType,
@@ -55,6 +58,7 @@ export function performanceHeaders(meta) {
55
58
  * @returns {OutgoingHttpHeaders}
56
59
  */
57
60
  export function customHeaders(meta) {
58
- const m = new Map(meta.customHeaders?.filter(h => h[0].startsWith('X-')))
61
+ const CUSTOM_HEADER_PREFIX = 'X-'
62
+ const m = new Map(meta.customHeaders?.filter(h => h[0].startsWith(CUSTOM_HEADER_PREFIX)))
59
63
  return Object.fromEntries(m)
60
64
  }
@@ -1,14 +1,9 @@
1
1
  import http2 from 'node:http2'
2
- import { pipeline, Readable } from 'node:stream'
2
+ import { compose, pipeline, Readable } from 'node:stream'
3
3
  import { ReadableStream } from 'node:stream/web'
4
- import {
5
- brotliCompressSync,
6
- deflateSync,
7
- gzipSync,
8
- zstdCompressSync
9
- } from 'node:zlib'
4
+ import zlib from 'node:zlib'
10
5
 
11
- import { HTTP_HEADER_ACCEPT_QUERY } from '../defs.js'
6
+ import { COMMON_LIST_VALUE_JOINER_COMMA, HTTP_HEADER_ACCEPT_QUERY } from '../defs.js'
12
7
  import { CacheControl } from '../headers/cache-control.js'
13
8
  import { Conditional } from '../headers/conditional.js'
14
9
  import { ContentRange } from '../headers/content-range.js'
@@ -21,7 +16,7 @@ import {
21
16
 
22
17
  /** @import { ServerHttp2Stream } from 'node:http2' */
23
18
  /** @import { OutgoingHttpHeaders } from 'node:http2' */
24
- /** @import { InputType } from 'node:zlib' */
19
+ /** @import { InputType, BrotliOptions, ZlibOptions, ZstdOptions } from 'node:zlib' */
25
20
  /** @import { AcceptRangeUnits, Metadata, SendBody } from '../defs.js' */
26
21
  /** @import { EtagItem, IMFFixDateInput } from '../headers/conditional.js' */
27
22
  /** @import { CacheControlOptions } from '../headers/cache-control.js' */
@@ -49,6 +44,18 @@ const {
49
44
  HTTP2_HEADER_RETRY_AFTER
50
45
  } = http2.constants
51
46
 
47
+ /**
48
+ * @param {ServerHttp2Stream} stream
49
+ * @param {number} status
50
+ * @param {OutgoingHttpHeaders} headers
51
+ * @param {Array<string>} exposedHeaders
52
+ * @param {Metadata} meta
53
+ */
54
+ export function send_no_body(stream, status, headers, exposedHeaders, meta) {
55
+ send(stream, status, headers, exposedHeaders, undefined, undefined, meta)
56
+ }
57
+
58
+
52
59
  /**
53
60
  * @param {ServerHttp2Stream} stream
54
61
  * @param {number} status
@@ -72,12 +79,56 @@ export function send_error(stream, status, message, retryAfter, meta) {
72
79
 
73
80
  /** @type {Map<string, EncoderFun>} */
74
81
  export const ENCODER_MAP = new Map([
75
- [ 'br', data => brotliCompressSync(data) ],
76
- [ 'gzip', data => gzipSync(data) ],
77
- [ 'deflate', data => deflateSync(data) ],
78
- [ 'zstd', data => zstdCompressSync(data) ]
82
+ [ 'br', data => zlib.brotliCompressSync(data) ],
83
+ [ 'gzip', data => zlib.gzipSync(data) ],
84
+ [ 'deflate', data => zlib.deflateSync(data) ],
85
+ [ 'zstd', data => zlib.zstdCompressSync(data) ]
86
+ ])
87
+
88
+ /** @type {BrotliOptions} */
89
+ export const ENCODER_STREAM_BR_OPTIONS = { }
90
+
91
+ /** @type {ZlibOptions} */
92
+ export const ENCODER_STREAM_GZIP_OPTIONS = {}
93
+
94
+ /** @type {ZlibOptions} */
95
+ export const ENCODER_STREAM_DEFLATE_OPTIONS = {}
96
+
97
+ /** @type {ZstdOptions} */
98
+ export const ENCODER_STREAM_ZSTD_OPTIONS = {}
99
+
100
+ /** @typedef {(stream: Readable) => Readable} EncoderStreamFn */
101
+
102
+ /** @type {Map<string, EncoderStreamFn>} */
103
+ export const ENCODER_STREAM_MAP = new Map([
104
+ [ 'br', stream => compose(stream, zlib.createBrotliCompress(ENCODER_STREAM_BR_OPTIONS)) ],
105
+ [ 'gzip', stream => compose(stream, zlib.createGzip(ENCODER_STREAM_GZIP_OPTIONS)) ],
106
+ [ 'deflate', stream => compose(stream, zlib.createDeflate(ENCODER_STREAM_DEFLATE_OPTIONS)) ],
107
+ [ 'zstd', stream => compose(stream, zlib.createZstdCompress(ENCODER_STREAM_ZSTD_OPTIONS)) ]
79
108
  ])
80
109
 
110
+
111
+ /**
112
+ * @template T
113
+ * @param {string|undefined|'identity'} encoding
114
+ * @param {Map<string, T>} listing
115
+ * @returns {{ encoderFn: T | undefined, encoding: string | undefined }}
116
+ */
117
+ export function lookupEncoder(encoding, listing) {
118
+ const encoderFn = listing.get(encoding ?? 'identity')
119
+ if(encoderFn === undefined) {
120
+ return {
121
+ encoderFn: undefined,
122
+ encoding: encoding === 'identity' ? encoding : undefined
123
+ }
124
+ }
125
+
126
+ return {
127
+ encoderFn,
128
+ encoding
129
+ }
130
+ }
131
+
81
132
  /**
82
133
  * @param {ServerHttp2Stream} stream
83
134
  * @param {number} status
@@ -95,13 +146,16 @@ export const ENCODER_MAP = new Map([
95
146
  export function send_encoded(stream, status, contentType, body, encoding, etag, lastModified, age, cacheControl, acceptRanges, supportedQueryTypes, meta) {
96
147
  const obj = (typeof body === 'string') ? Buffer.from(body, CHARSET_UTF8) : body
97
148
 
98
- const useIdentity = encoding === 'identity'
99
- const encoder = encoding === undefined ? undefined : ENCODER_MAP.get(encoding)
100
- const hasEncoder = encoder !== undefined
101
- const actualEncoding = hasEncoder ? encoding : undefined
149
+ if((obj instanceof ReadableStream) || (obj instanceof Readable)) {
150
+ const { encoderFn, encoding: actualEncoding } = lookupEncoder(encoding, ENCODER_STREAM_MAP)
151
+ const encodedStream = (encoderFn === undefined) ? obj : encoderFn((obj instanceof ReadableStream) ? Readable.fromWeb(obj) : obj)
152
+ send_bytes(stream, status, contentType, encodedStream, undefined, undefined, actualEncoding, etag, lastModified, age, cacheControl, acceptRanges, supportedQueryTypes, meta )
153
+ return
154
+ }
102
155
 
156
+ const { encoderFn, encoding: actualEncoding } = lookupEncoder(encoding, ENCODER_MAP)
103
157
  const encodeStart = performance.now()
104
- const encodedData = (hasEncoder && !useIdentity) ? encoder(obj) : obj
158
+ const encodedData = (encoderFn === undefined) ? obj : encoderFn(obj)
105
159
  const encodeEnd = performance.now()
106
160
 
107
161
  meta.performance.push(
@@ -142,7 +196,7 @@ export function send_bytes(stream, status, contentType, obj, range, contentLengt
142
196
 
143
197
  send(stream, status, {
144
198
  [HTTP2_HEADER_CONTENT_ENCODING]: encoding,
145
- [HTTP2_HEADER_VARY]: varyHeaders.join(','),
199
+ [HTTP2_HEADER_VARY]: varyHeaders.join(COMMON_LIST_VALUE_JOINER_COMMA),
146
200
  [HTTP2_HEADER_CACHE_CONTROL]: CacheControl.encode(cacheControl),
147
201
  [HTTP2_HEADER_ETAG]: Conditional.encodeEtag(etag),
148
202
  [HTTP2_HEADER_LAST_MODIFIED]: Conditional.encodeFixDate(lastModified),
@@ -150,7 +204,7 @@ export function send_bytes(stream, status, contentType, obj, range, contentLengt
150
204
  [HTTP2_HEADER_CONTENT_LENGTH]: contentLen,
151
205
  [HTTP2_HEADER_CONTENT_RANGE]: ContentRange.encode(range),
152
206
  [HTTP2_HEADER_ACCEPT_RANGES]: acceptRanges,
153
- [HTTP_HEADER_ACCEPT_QUERY]: supportedQueryTypes?.join(',')
207
+ [HTTP_HEADER_ACCEPT_QUERY]: supportedQueryTypes?.join(COMMON_LIST_VALUE_JOINER_COMMA)
154
208
  }, exposedHeaders, contentType, obj, meta)
155
209
  }
156
210
 
@@ -186,10 +240,9 @@ export function send(stream, status, headers, exposedHeaders, contentType, body,
186
240
  }
187
241
 
188
242
  if(stream.writable && body !== undefined) {
189
- if(body instanceof ReadableStream) {
190
- const signal = undefined // AbortSignal.timeout(1000)
243
+ if(body instanceof ReadableStream || body instanceof Readable) {
191
244
  pipeline(
192
- Readable.fromWeb(body, { signal }),
245
+ body,
193
246
  stream,
194
247
  err => {
195
248
  if(err !== null && err !== undefined) {
@@ -206,4 +259,4 @@ export function send(stream, status, headers, exposedHeaders, contentType, body,
206
259
 
207
260
  stream.end()
208
261
  // if(!stream.closed) { stream.close() }
209
- }
262
+ }