@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 +21 -0
- package/README.md +44 -0
- package/package.json +18 -0
- package/src/accept-encoding.js +53 -0
- package/src/accept-language.js +46 -0
- package/src/accept-util.js +55 -0
- package/src/accept.js +125 -0
- package/src/body.js +320 -0
- package/src/content-disposition.js +52 -0
- package/src/content-type.js +140 -0
- package/src/forwarded.js +145 -0
- package/src/handle-stream-util.js +262 -0
- package/src/index.js +10 -0
- package/src/multipart.js +149 -0
- package/src/rate-limit.js +84 -0
- package/src/server-timing.js +51 -0
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
|
+
}
|