@johntalton/http-util 1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # http-util
2
+
3
+ Set of utilities to aid in building from-scratch [node:http2](https://nodejs.org/docs/latest/api/http2.html) stream compatible services.
4
+
5
+ - Header parsers
6
+ - Stream Response methods
7
+ - Body parser
8
+
9
+ ## Header Parsers
10
+ ### From Client:
11
+ - Accept Encoding - `parse` and `select` based on server/client values
12
+ - Accept Language - `parse` and `select`
13
+ - Accept - `parse` and `select`
14
+
15
+ - Content Type - returns structured data object for use with Body/etc
16
+ - Forwarded - `parse` with select-right-most helper
17
+ - Multipart - parse into `FormData`
18
+ - Content Disposition - for use inside of Multipart
19
+
20
+ ### Server Sent:
21
+ - Rate Limit
22
+ - Server Timing
23
+
24
+ ## Response
25
+
26
+ All responders take in a `stream` as well as a metadata object to hint on servername and origin strings etc.
27
+
28
+ - `sendError` - 500
29
+ - `sendPreflight` - Response to OPTIONS with CORS headers
30
+ - `sendUnauthorized` - Unauthorized
31
+ - `sendNotFound` - 404
32
+ - `sendTooManyRequests` - Rate limit response (429)
33
+ - `sendJSON_Encoded` - Standard Ok response with encoding
34
+ - `sendSSE` - SSE header (leave the `stream` open)
35
+
36
+ Responses allow for optional CORS headers as well as Server Timing meta data.
37
+
38
+ ## Body
39
+
40
+ The `requestBody` method returns a `fetch`-like response. Including methods `blob`, `arrayBuffer`, `bytes`, `text`, `formData`, `json` as well as a `body` as a `ReadableStream`.
41
+
42
+ The return is a deferred response that does NOT consume the `steam` until calling one of the above methods.
43
+
44
+ Optional `byteLimit`, `contentLength` and `contentType` can be provided to hint the parser, as well as a `AbortSignal` to abandoned the reader.
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@johntalton/http-util",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./headers": "./src/index.js",
9
+ "./body": "./src/body.js",
10
+ "./response": "./src/handle-stream-util.js"
11
+ },
12
+ "files": [
13
+ "src/*.js"
14
+ ],
15
+ "dependencies": {
16
+ "@johntalton/sse-util": "^1.0.0"
17
+ }
18
+ }
@@ -0,0 +1,53 @@
1
+ import { parseAcceptStyleHeader } from './accept-util.js'
2
+
3
+ /** @import { AcceptStyleItem } from './accept-util.js' */
4
+
5
+ export const WELL_KNOWN_ENCODINGS = new Map([
6
+ [ 'gzip, deflate, br, zstd', [ { name: 'gzip' }, { name: 'deflate' }, { name: 'br' }, { name: 'zstd' } ] ],
7
+ [ 'gzip, deflate, br', [ { name: 'gzip' }, { name: 'deflate' }, { name: 'br' } ] ]
8
+ ])
9
+
10
+ export class AcceptEncoding {
11
+ /**
12
+ * @param {string|undefined} acceptEncodingHeader
13
+ */
14
+ static parse(acceptEncodingHeader) {
15
+ return parseAcceptStyleHeader(acceptEncodingHeader, WELL_KNOWN_ENCODINGS)
16
+ }
17
+
18
+ /**
19
+ * @param {string|undefined} acceptEncodingHeader
20
+ * @param {Array<string>} supportedTypes
21
+ */
22
+ static select(acceptEncodingHeader, supportedTypes) {
23
+ const accepts = AcceptEncoding.parse(acceptEncodingHeader)
24
+ return this.selectFrom(accepts, supportedTypes)
25
+ }
26
+
27
+ /**
28
+ * @param {Array<AcceptStyleItem>} acceptEncodings
29
+ * @param {Array<string>} supportedTypes
30
+ */
31
+ static selectFrom(acceptEncodings, supportedTypes) {
32
+ for(const acceptEncoding of acceptEncodings) {
33
+ const { name } = acceptEncoding
34
+ if(supportedTypes.includes(name)) {
35
+ return name
36
+ }
37
+ }
38
+
39
+ return undefined
40
+ }
41
+ }
42
+
43
+
44
+ // console.log(AcceptEncoding.parse(''))
45
+ // console.log(AcceptEncoding.parse(' '))
46
+ // console.log(AcceptEncoding.parse('zstd'))
47
+ // console.log(AcceptEncoding.parse('identity'))
48
+ // console.log(AcceptEncoding.parse('*'))
49
+ // console.log(AcceptEncoding.parse('gzip, deflate, br, zstd'))
50
+ // console.log(AcceptEncoding.parse('br;q=1.0, gzip;q=0.8, *;q=0.1'))
51
+ // console.log(AcceptEncoding.parse('deflate, gzip;q=1.0, *;q=0.5'))
52
+ // console.log(AcceptEncoding.parse('identity;q=0'))
53
+ // console.log(AcceptEncoding.parse('*;q=0'))
@@ -0,0 +1,46 @@
1
+ import { parseAcceptStyleHeader } from './accept-util.js'
2
+
3
+ /**
4
+ * @import { AcceptStyleItem } from './accept-util.js'
5
+ */
6
+
7
+ export const WELL_KNOWN_LANGUAGES = new Map([
8
+ [ 'en-US,en;q=0.5', [ { name: 'en-US', quality: 1 }, { name: 'en', quality: 0.5 } ] ],
9
+ [ 'en-US,en;q=0.9', [ { name: 'en-US', quality: 1 }, { name: 'en', quality: 0.9 } ] ]
10
+ ])
11
+
12
+ export class AcceptLanguage {
13
+ /**
14
+ * @param {string|undefined} acceptLanguageHeader
15
+ */
16
+ static parse(acceptLanguageHeader) {
17
+ return parseAcceptStyleHeader(acceptLanguageHeader, WELL_KNOWN_LANGUAGES)
18
+ }
19
+
20
+ /**
21
+ * @param {string|undefined} acceptLanguageHeader
22
+ * @param {Array<string>} supportedTypes
23
+ */
24
+ static select(acceptLanguageHeader, supportedTypes) {
25
+ const accepts = AcceptLanguage.parse(acceptLanguageHeader)
26
+ return this.selectFrom(accepts, supportedTypes)
27
+ }
28
+
29
+ /**
30
+ * @param {Array<AcceptStyleItem>} acceptLanguages
31
+ * @param {Array<string>} supportedTypes
32
+ */
33
+ static selectFrom(acceptLanguages, supportedTypes) {
34
+ for(const acceptLanguage of acceptLanguages) {
35
+ const { name } = acceptLanguage
36
+ if(supportedTypes.includes(name)) {
37
+ return name
38
+ }
39
+ }
40
+
41
+ return undefined
42
+ }
43
+ }
44
+
45
+ // console.log(AcceptLanguage.parse('en-US,en;q=0.9'))
46
+ // console.log(AcceptLanguage.select('foo;q=0.2, bar-BZ', [ 'bang', 'foo' ]))
@@ -0,0 +1,55 @@
1
+ export const QUALITY = 'q'
2
+ export const SEPARATOR = {
3
+ MEDIA_RANGE: ',',
4
+ PARAMETER: ';',
5
+ KVP: '='
6
+ }
7
+
8
+ export const DEFAULT_QUALITY_STRING = '1'
9
+
10
+ /**
11
+ * @typedef {Object} AcceptStyleItem
12
+ * @property {string} name
13
+ * @property {number|undefined} [quality]
14
+ * @property {Map<string, string>|undefined} [parameters]
15
+ */
16
+
17
+ /**
18
+ * @param {string|undefined} header
19
+ * @param {Map<string, Array<AcceptStyleItem>>} [wellKnown]
20
+ * @returns {Array<AcceptStyleItem>}
21
+ */
22
+ export function parseAcceptStyleHeader(header, wellKnown) {
23
+ if(header === undefined) { return [] }
24
+
25
+ const wk = wellKnown?.get(header)
26
+ if(wk !== undefined) { return wk }
27
+
28
+ return header
29
+ .trim()
30
+ .split(SEPARATOR.MEDIA_RANGE)
31
+ .map(mediaRange => {
32
+ const [ name, ...parametersSet ] = mediaRange
33
+ .trim()
34
+ .split(SEPARATOR.PARAMETER)
35
+
36
+ const parameters = new Map(parametersSet.map(parameter => {
37
+ const [ key, value ] = parameter.split(SEPARATOR.KVP).map(p => p.trim())
38
+ return [ key, value ]
39
+ }))
40
+
41
+ if(!parameters.has(QUALITY)) { parameters.set(QUALITY, DEFAULT_QUALITY_STRING) }
42
+ const quality = parseFloat(parameters.get(QUALITY) ?? DEFAULT_QUALITY_STRING)
43
+
44
+ return {
45
+ name,
46
+ quality,
47
+ parameters
48
+ }
49
+ })
50
+ .filter(entry => entry.name !== undefined && entry.name !== '')
51
+ .sort((entryA, entryB) => {
52
+ // B - A descending order
53
+ return entryB.quality - entryA.quality
54
+ })
55
+ }
package/src/accept.js ADDED
@@ -0,0 +1,125 @@
1
+ import { parseAcceptStyleHeader } from './accept-util.js'
2
+
3
+ /**
4
+ * @import { AcceptStyleItem } from './accept-util.js'
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} AcceptExtensionItem
9
+ * @property {string} mimetype
10
+ * @property {string} type
11
+ * @property {string} subtype
12
+ */
13
+
14
+ /**
15
+ * @typedef {AcceptStyleItem & AcceptExtensionItem} AcceptItem
16
+ */
17
+
18
+ export const ACCEPT_SEPARATOR = { SUBTYPE: '/' }
19
+ export const ACCEPT_ANY = '*'
20
+
21
+ export const WELL_KNOWN = new Map([
22
+ [ '*/*', [ { name: '*/*', quality: 1 } ] ],
23
+ [ 'application/json', [ { name: 'application/json', quality: 1 } ] ]
24
+ ])
25
+
26
+ export class Accept {
27
+ /**
28
+ * @param {string|undefined} acceptHeader
29
+ * @returns {Array<AcceptItem>}
30
+ */
31
+ static parse(acceptHeader) {
32
+ return parseAcceptStyleHeader(acceptHeader, WELL_KNOWN)
33
+ .map(({ name, quality, parameters }) => {
34
+ const [ type, subtype ] = name
35
+ .split(ACCEPT_SEPARATOR.SUBTYPE)
36
+ .map(t => t.trim())
37
+
38
+ return {
39
+ mimetype: `${type}${ACCEPT_SEPARATOR.SUBTYPE}${subtype ?? ACCEPT_ANY}`,
40
+ name, type, subtype,
41
+ quality,
42
+ parameters
43
+ }
44
+ })
45
+ .sort((entryA, entryB) => {
46
+ if(entryA.quality === entryB.quality) {
47
+ // prefer things with less ANY
48
+ const specificityA = (entryA.type === ACCEPT_ANY ? 1 : 0) + (entryA.subtype === ACCEPT_ANY ? 1 : 0)
49
+ const specificityB = (entryB.type === ACCEPT_ANY ? 1 : 0) + (entryB.subtype === ACCEPT_ANY ? 1 : 0)
50
+ return specificityA - specificityB
51
+ }
52
+
53
+ // B - A descending order
54
+ const qualityB = entryB.quality ?? 0
55
+ const qualityA = entryA.quality ?? 0
56
+ return qualityB - qualityA
57
+ // return entryB.quality - entryA.quality
58
+ })
59
+ }
60
+
61
+ /**
62
+ * @param {string|undefined} acceptHeader
63
+ * @param {Array<string>} supportedTypes
64
+ */
65
+ static select(acceptHeader, supportedTypes) {
66
+ const accepts = Accept.parse(acceptHeader)
67
+ return this.selectFrom(accepts, supportedTypes)
68
+ }
69
+
70
+ /**
71
+ * @param {Array<AcceptItem>} accepts
72
+ * @param {Array<string>} supportedTypes
73
+ */
74
+ static selectFrom(accepts, supportedTypes) {
75
+ const bests = accepts.map(accept => {
76
+ const { type, subtype, quality } = accept
77
+ const st = supportedTypes.filter(supportedType => {
78
+ const [ stType, stSubtype ] = supportedType.split(ACCEPT_SEPARATOR.SUBTYPE)
79
+ return ((stType === type || type === ACCEPT_ANY) && (stSubtype === subtype || subtype === ACCEPT_ANY))
80
+ })
81
+
82
+ return {
83
+ supportedTypes: st,
84
+ quality
85
+ }
86
+ })
87
+ .filter(best => {
88
+ return best.supportedTypes.length > 0
89
+ })
90
+
91
+ if(bests.length === 0) { return undefined }
92
+ const [ first ] = bests
93
+ if(first === undefined) { return undefined }
94
+ const [ firstSt ] = first.supportedTypes
95
+ return firstSt
96
+ }
97
+ }
98
+
99
+ // console.log(Accept.parse('text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, text/*;q=.8, */*;q=0.7'))
100
+ // console.log(Accept.select('text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, text/*;q=.8, */*;q=0.7', [ 'application/json', 'text/plain' ]))
101
+
102
+ // const tests = [
103
+ // undefined,
104
+ // '',
105
+ // ' ',
106
+ // ' fake',
107
+ // ' application/json',
108
+ // ' application/xml,',
109
+ // ' ,application/xml ,,',
110
+ // ' audio/*; q=0.2, audio/basic',
111
+ // ' text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, */*;q=0.8',
112
+ // ' text/*;q=0.3, text/plain;q=0.7, text/plain;format=flowed,\ntext/plain;format=fixed;q=0.4, */*;q=0.5',
113
+
114
+ // ' */*, foo/bar, foo/*, biz/bang, */*;q=.2, quix/quak;q=.1',
115
+ // 'foo / bar ; q = .5'
116
+ // ]
117
+
118
+ // tests.forEach(test => {
119
+ // const result = Accept.parse(test)
120
+ // console.log('=============================')
121
+ // console.log({ test })
122
+ // console.log('---')
123
+ // console.log(result)
124
+ // })
125
+
package/src/body.js ADDED
@@ -0,0 +1,320 @@
1
+ import { CHARSET_UTF8, MIME_TYPE_MULTIPART_FORM_DATA, MIME_TYPE_URL_FORM_DATA } from './content-type.js'
2
+ import { Multipart } from './multipart.js'
3
+
4
+ export const DEFAULT_BYTE_LIMIT = 1024 * 1024 //
5
+
6
+ /**
7
+ * @import { Readable } from 'node:stream'
8
+ */
9
+
10
+ /**
11
+ * @import { ContentType } from './content-type.js'
12
+ */
13
+
14
+ /**
15
+ * @typedef {Object} BodyOptions
16
+ * @property {AbortSignal} [signal]
17
+ * @property {number} [byteLimit]
18
+ * @property {number} [contentLength]
19
+ * @property {ContentType|undefined} [contentType]
20
+ */
21
+
22
+ /**
23
+ * @typedef {Object} BodyFuture
24
+ * @property {number} duration
25
+ * @property {ReadableStream} body
26
+ * @property {ContentType|undefined} contentType
27
+ * @property { (mimetype: string) => Promise<Blob> } blob
28
+ * @property { () => Promise<ArrayBufferLike> } arrayBuffer
29
+ * @property { () => Promise<Uint8Array> } bytes
30
+ * @property { () => Promise<string> } text
31
+ * @property { () => Promise<FormData>} formData
32
+ * @property { () => Promise<any> } json
33
+ */
34
+
35
+
36
+ /**
37
+ * @param {Readable} stream
38
+ * @param {BodyOptions} [options]
39
+ * @returns {BodyFuture}
40
+ */
41
+ export function requestBody(stream, options) {
42
+ const signal = options?.signal
43
+ const byteLimit = options?.byteLimit ?? DEFAULT_BYTE_LIMIT
44
+ const contentLength = options?.contentLength
45
+ const charset = options?.contentType?.charset ?? CHARSET_UTF8
46
+ const contentType = options?.contentType
47
+
48
+ const invalidContentLength = (contentLength === undefined || isNaN(contentLength))
49
+ // if(contentLength > byteLimit) {
50
+ // console.log(contentLength, invalidContentLength)
51
+ // throw new Error('contentLength exceeds limit')
52
+ // }
53
+
54
+ // console.log('closed/errored', stream.closed, stream.errored)
55
+ // console.log('readable length', stream.readableLength)
56
+ // console.log('readable/ended', stream.readable, stream.readableEnded)
57
+
58
+ const stats = {
59
+ byteLength: 0,
60
+ closed: false,
61
+ duration: 0
62
+ }
63
+
64
+ // console.log('create body reader, underlying source')
65
+
66
+ /** @type {UnderlyingDefaultSource} */
67
+ const underlyingSource = {
68
+ start(controller) {
69
+ // console.log('body reader start')
70
+
71
+ if(invalidContentLength) {
72
+ // console.log('invalid content length')
73
+
74
+ // stats.closed = true
75
+ // controller.close()
76
+ // return
77
+ }
78
+
79
+ if(contentLength === 0) {
80
+ // console.log('zero content length')
81
+
82
+ stats.closed = true
83
+ controller.close()
84
+ return
85
+ }
86
+
87
+ if(stream.readableLength === 0) {
88
+ // console.log('body has zero bytes')
89
+
90
+ // stats.closed = true
91
+ // controller.close()
92
+ // return
93
+ }
94
+
95
+
96
+ const listener = () => {
97
+ controller.error(new Error('Abort Signal Timed out'))
98
+ }
99
+
100
+ signal?.addEventListener('abort', listener, { once: true })
101
+
102
+ stream.on('data', chunk => {
103
+ if(signal?.aborted) {
104
+ console.log('body reader aborted')
105
+ controller.error(new Error('Chunk read Abort Signal Timed out'))
106
+ stats.closed = true
107
+ return
108
+ }
109
+
110
+ if(stats.closed) {
111
+ console.log('late chunk already closed')
112
+ stats.closed = true
113
+ return
114
+ }
115
+
116
+ // chunk is a node Buffer (which is a TypedArray)
117
+ if(!ArrayBuffer.isView(chunk)) {
118
+ controller.error('invalid chunk type')
119
+ stats.closed = true
120
+ }
121
+
122
+ stats.byteLength += chunk.byteLength
123
+
124
+ if(stats.byteLength > byteLimit) {
125
+ console.log('body exceed byte limit', stats.byteLength)
126
+ controller.error(new Error('body exceed byte limit'))
127
+ // stream.close()
128
+ stats.closed = true
129
+ return
130
+ }
131
+
132
+ // console.log('body reader chunk', stats.byteLength)
133
+ controller.enqueue(chunk)
134
+ })
135
+
136
+ stream.on('end', () => {
137
+ // console.log('body reader end')
138
+ signal?.removeEventListener('abort', listener)
139
+
140
+ if(!stats.closed) {
141
+ // console.log('body reader close controller on end')
142
+ stats.closed = true
143
+ controller.close()
144
+ }
145
+ })
146
+
147
+ stream.on('close', () => {
148
+ // console.log('body reader stream close')
149
+ if(!stats.closed) {
150
+ stats.closed = true
151
+ controller.close()
152
+ }
153
+ })
154
+ stream.on('aborted', () => console.log('body reader stream aborted'))
155
+ },
156
+
157
+ cancel(reason) {
158
+ console.log('body reader canceled', reason)
159
+ }
160
+ }
161
+
162
+ /**
163
+ * @returns {ReadableStream}
164
+ */
165
+ function makeReader() {
166
+ if(stats.closed) { throw new Error('body already consumed') }
167
+ // console.log('makeReader')
168
+ return new ReadableStream(underlyingSource)
169
+ }
170
+
171
+ /**
172
+ * @template T
173
+ * @param {(reader: ReadableStream) => Promise<T>} futureFn
174
+ */
175
+ async function wrap(futureFn) {
176
+ const start = performance.now()
177
+ const reader = makeReader()
178
+ // console.log(reader)
179
+ const result = await futureFn(reader)
180
+ // console.log(result)
181
+ const end = performance.now()
182
+ const duration = end - start
183
+ stats.duration = duration
184
+ return result
185
+ }
186
+
187
+ return {
188
+ get duration() { return stats.duration },
189
+ get body() { return makeReader() },
190
+ get contentType() { return contentType },
191
+
192
+ blob: (/** @type {string | undefined} */ mimetype) => wrap(reader => bodyBlob(reader, mimetype ?? contentType?.mimetype)),
193
+ arrayBuffer: () => wrap(reader => bodyArrayBuffer(reader)),
194
+ bytes: () => wrap(reader => bodyUint8Array(reader)),
195
+ text: () => wrap(reader => bodyText(reader, charset)),
196
+ formData: () => wrap(reader => bodyFormData(reader, contentType)),
197
+ json: () => wrap(reader => bodyJSON(reader, charset))
198
+ }
199
+ }
200
+
201
+ /**
202
+ * @param {ReadableStream} reader
203
+ * @param {string} [mimetype]
204
+ */
205
+ export async function bodyBlob(reader, mimetype) {
206
+ const parts = []
207
+ for await (const part of reader) {
208
+ // console.log('push part', part.length)
209
+ parts.push(part)
210
+ }
211
+
212
+ // console.log('Blob')
213
+ return new Blob(parts, { type: mimetype ?? '' })
214
+ }
215
+
216
+ /**
217
+ * @param {ReadableStream} reader
218
+ */
219
+ export async function bodyArrayBuffer(reader) {
220
+ const blob = await bodyBlob(reader)
221
+ return blob.arrayBuffer()
222
+
223
+ // const u8 = await bodyUint8Array(reader)
224
+ // return u8.buffer
225
+ }
226
+
227
+ /**
228
+ * @param {ReadableStream} reader
229
+ */
230
+ export async function bodyUint8Array(reader) {
231
+ const buffer = await bodyArrayBuffer(reader)
232
+ return new Uint8Array(buffer)
233
+
234
+ // let total = 0
235
+ // const parts = []
236
+ // for await (const part of reader) {
237
+ // total += part.byteLength
238
+ // parts.push(part)
239
+ // }
240
+
241
+ // const buffer = new Uint8Array(total)
242
+ // let offset = 0
243
+ // for (const part of parts) {
244
+ // buffer.set(part, offset)
245
+ // offset += part.byteLength
246
+ // }
247
+
248
+ // return buffer
249
+ }
250
+
251
+ /**
252
+ * @param {ReadableStream} reader
253
+ * @param {string} [charset]
254
+ */
255
+ export async function bodyText(reader, charset) {
256
+ // const blob = await bodyBlob(reader)
257
+ // return blob.text()
258
+
259
+ const u8 = await bodyUint8Array(reader)
260
+ const decoder = new TextDecoder(charset ?? CHARSET_UTF8, { fatal: true })
261
+ return decoder.decode(u8)
262
+ }
263
+
264
+ /**
265
+ * @param {ReadableStream} reader
266
+ * @param {string} [charset]
267
+ */
268
+ export async function bodyJSON(reader, charset) {
269
+ // console.log('bodyJSON')
270
+ const text = await bodyText(reader, charset)
271
+ return (text === '') ? {} : JSON.parse(text)
272
+ }
273
+
274
+ /**
275
+ * @param {ReadableStream} reader
276
+ * @param {ContentType} contentType
277
+ */
278
+ async function _bodyFormData_Multipart(reader, contentType) {
279
+ const MULTIPART_FORM_DATA_BOUNDARY_PARAMETER = 'boundary'
280
+
281
+ const text = await bodyText(reader, contentType.charset)
282
+ const boundary = contentType.parameters.get(MULTIPART_FORM_DATA_BOUNDARY_PARAMETER)
283
+ if(boundary === undefined) { throw new Error('unspecified boundary') }
284
+
285
+ return Multipart.parse(text, boundary, contentType.charset)
286
+ }
287
+
288
+ /**
289
+ * @param {ReadableStream} reader
290
+ * @param {ContentType} contentType
291
+ */
292
+ async function _bodyFormData_URL(reader, contentType) {
293
+ const text = await bodyText(reader, contentType.charset)
294
+ const sp = new URLSearchParams(text)
295
+ const formData = new FormData()
296
+
297
+ for(const [ key, value ] of sp.entries()) {
298
+ formData.append(key, value)
299
+ }
300
+
301
+ return formData
302
+ }
303
+
304
+ /**
305
+ * @param {ReadableStream} reader
306
+ * @param {ContentType|undefined} contentType
307
+ */
308
+ export async function bodyFormData(reader, contentType) {
309
+ if(contentType === undefined) { throw new Error('undefined content type for form data') }
310
+
311
+ if(contentType.mimetype === MIME_TYPE_MULTIPART_FORM_DATA) {
312
+ return _bodyFormData_Multipart(reader, contentType)
313
+ }
314
+
315
+ if(contentType.mimetype === MIME_TYPE_URL_FORM_DATA) {
316
+ return _bodyFormData_URL(reader, contentType)
317
+ }
318
+
319
+ throw new TypeError('unknown mime type for form data')
320
+ }