@johntalton/http-util 4.1.0 → 5.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/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  Set of utilities to aid in building from-scratch [node:http2](https://nodejs.org/docs/latest/api/http2.html) stream compatible services.
4
4
 
5
- - Header parsers
6
- - Stream Response methods
7
- - Body parser
5
+ - [Header parsers](#header-parsers)
6
+ - [Stream Response methods](#response)
7
+ - [Body parser](#body)
8
8
 
9
9
  ## Header Parsers
10
10
  ### From Client:
@@ -44,24 +44,37 @@ const bestMatchingType = Accept.select(acceptHeader, supportedType)
44
44
 
45
45
  All responders take in a `stream` as well as a metadata object to hint on servername and origin strings etc.
46
46
 
47
- - `sendAccepted`
48
- - `sendConflict`
49
- - `sendCreated`
50
- - `sendError` - 500
51
- - `sendJSON_Encoded` - Standard Ok response with encoding
52
- - `sendNoContent`
53
- - `sendNotAcceptable`
54
- - `sendNotAllowed` - Method not supported / allowed
55
- - `sendNotFound` - 404
56
- - `sendNotModified`
57
- - `sendPreflight` - Response to OPTIONS with CORS headers
58
- - `sendTimeout`
59
- - `sendTooManyRequests` - Rate limit response (429)
60
- - `sendTrace`
61
- - `sendUnauthorized` - Unauthorized
62
- - `sendUnprocessable`
63
- - `sendUnsupportedMediaType`
64
- - `sendSSE` - SSE header (leave the `stream` open)
47
+ - [`sendAccepted`](#responseaccepted)
48
+ - [`sendConflict`](#responseconflict)
49
+ - [`sendContentTooLarge`](#)
50
+ - [`sendCreated`](#responsecreated)
51
+ - [`sendError`](#responseerror) - 500
52
+ - [`sendGone`](#)
53
+ - [`sendImATeapot`](#)
54
+ - [`sendInsufficientStorage`](#)
55
+ - [`sendJSON_Encoded`](#responsejson) - Standard Ok response with encoding
56
+ - [`sendMovedPermanently`](#)
57
+ - [`sendMultipleChoices`](#)
58
+ - [`sendNoContent`](#responsenocontent)
59
+ - [`sendNotAcceptable`](#responsenotacceptable)
60
+ - [`sendNotAllowed`](#responsenotallowed) - Method not supported / allowed
61
+ - [`sendNotFound`](#responsenotfound) - 404
62
+ - [`sendNotImplemented`](#responsenotimplemented)
63
+ - [`sendNotModified`](#responsenotmodified)
64
+ - [`sendPartialContent`](#)
65
+ - [`sendPreconditionFailed`](#responsepreconditionfailed)
66
+ - [`sendPreflight`](#responsepreflight) - Response to OPTIONS with CORS headers
67
+ - [`sendRangeNotSatisfiable`](#)
68
+ - [`sendSeeOther`](#)
69
+ - [`sendTemporaryRedirect`](#)
70
+ - [`sendTimeout`](#responsetimeout)
71
+ - [`sendTooManyRequests`](#responsetoomanyrequests) - Rate limit response (429)
72
+ - [`sendTrace`](#responsetrace)
73
+ - [`sendUnauthorized`](#responseunauthorized) - Unauthorized
74
+ - [`sendUnavailable`](#responseunavailable)
75
+ - [`sendUnprocessable`](#responseunprocessable)
76
+ - [`sendUnsupportedMediaType`](#responseunsupportedmediatype)
77
+ - [`sendSSE`](#responsesse) - SSE header (leave the `stream` open)
65
78
 
66
79
  Responses allow for optional CORS headers as well as Server Timing meta data.
67
80
 
@@ -102,3 +115,201 @@ const futureBody = requestBody(stream, {
102
115
  const body = futureBody.json()
103
116
 
104
117
  ```
118
+
119
+ ## Response method API
120
+
121
+ All response methods take in the `stream` and a `meta` property.
122
+
123
+ Additional parameters are specific to each return type.
124
+
125
+ Each return type semantic may also expose header (in addition to the standard headers) to the calling code as needed.
126
+
127
+ Some common objects can be passed such as `EtagItem`, `CacheControlOptions` and `Metadata` have their own structure.
128
+
129
+ Many parameters accept `undefined` to skip/ignore the usage
130
+
131
+ ### Response.accepted
132
+
133
+ Parameters:
134
+ - stream
135
+ - meta
136
+
137
+ ### Response.conflict
138
+
139
+ Parameters:
140
+ - stream
141
+ - meta
142
+
143
+
144
+ ### Response.created
145
+
146
+ Parameters:
147
+ - stream
148
+ - location
149
+ - etag
150
+ - meta
151
+
152
+ Additional Exposed Headers:
153
+ - location
154
+
155
+ ### Response.error
156
+
157
+ Parameters:
158
+ - stream
159
+ - meta
160
+
161
+
162
+ ### Response.json
163
+
164
+ Parameters:
165
+ - stream
166
+ - object
167
+ - encoding - undefined | 'identity' | 'br' | 'gzip' | 'deflate' | 'zstd'
168
+ - etag
169
+ - age
170
+ - cacheControl
171
+ - supportedQueryTypes - undefined | Array of supported query types
172
+ - meta
173
+
174
+ Additional Exposed Headers
175
+ - age
176
+ - accept-query
177
+
178
+ ### Response.noContent
179
+
180
+ Parameters:
181
+ - stream
182
+ - etag
183
+ - meta
184
+
185
+ ### Response.notAcceptable
186
+
187
+ Parameters:
188
+ - stream
189
+ - supportedTypes
190
+ - meta
191
+
192
+ ### Response.notAllowed
193
+
194
+ Parameters:
195
+ - stream
196
+ - methods - Array of allowed methods
197
+ - meta
198
+
199
+ Additional Exposed Headers:
200
+ - allow
201
+
202
+ ### Response.notFound
203
+
204
+ Parameters:
205
+ - stream
206
+ - message
207
+ - meta
208
+
209
+ ### Response.notImplemented
210
+
211
+ Parameters:
212
+ - stream
213
+ - message
214
+ - meta
215
+
216
+ ### Response.notModified
217
+
218
+ Parameters:
219
+ - stream
220
+ - etag
221
+ - age
222
+ - cacheControl
223
+ - meta
224
+
225
+ Additional Exposed Headers:
226
+ - age
227
+
228
+ ### Response.preconditionFailed
229
+
230
+ Parameters:
231
+ - stream
232
+ - meta
233
+
234
+ ### Response.preflight
235
+
236
+ Parameters:
237
+ - stream
238
+ - methods
239
+ - supportedQueryTypes - undefined | Array of supported types
240
+ - meta
241
+
242
+ Additional Exposed Headers:
243
+ - accept-query
244
+
245
+ ### Response.sse
246
+
247
+ Parameters:
248
+ - stream
249
+ - meta
250
+
251
+
252
+ ### Response.timeout
253
+
254
+ Parameters:
255
+ - stream
256
+ - meta
257
+
258
+
259
+ ### Response.tooManyRequests
260
+
261
+ Parameters:
262
+ - stream
263
+ - limitInfo - `RateLimitInfo`
264
+ - policies - Array of `RateLimitPolicyInfo`
265
+ - meta
266
+
267
+ Additional Exposed Headers:
268
+ - retry-after
269
+ - rate-limit
270
+ - rate-limit-policy
271
+
272
+ ### Response.trace
273
+
274
+ Parameters:
275
+ - stream
276
+ - method
277
+ - url
278
+ - headers
279
+ - meta
280
+
281
+ ### Response.unauthorized
282
+
283
+ Parameters:
284
+ - stream
285
+ - meta
286
+
287
+
288
+ ### Response.unavailable
289
+
290
+ Parameters:
291
+ - stream
292
+ - message
293
+ - retryAfter
294
+ - meta
295
+
296
+ Additional Exposed Headers:
297
+ - retry-after
298
+
299
+ ### Response.unprocessable
300
+
301
+ Parameters:
302
+ - stream
303
+ - meta
304
+
305
+ ### Response.unsupportedMediaType
306
+
307
+ Parameters:
308
+ - stream
309
+ - acceptableMediaTypes
310
+ - supportedQueryTypes
311
+ - meta
312
+
313
+ Additional Exposed Headers:
314
+ - accept-query
315
+ - accept-post
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johntalton/http-util",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
package/src/body.js CHANGED
@@ -1,15 +1,14 @@
1
- import { CHARSET_UTF8, MIME_TYPE_MULTIPART_FORM_DATA, MIME_TYPE_URL_FORM_DATA } from './content-type.js'
1
+ import {
2
+ CHARSET_UTF8,
3
+ MIME_TYPE_MULTIPART_FORM_DATA,
4
+ MIME_TYPE_URL_FORM_DATA
5
+ } from './content-type.js'
2
6
  import { Multipart } from './multipart.js'
3
7
 
4
8
  export const DEFAULT_BYTE_LIMIT = 1024 * 1024 //
5
9
 
6
- /**
7
- * @import { Readable } from 'node:stream'
8
- */
9
-
10
- /**
11
- * @import { ContentType } from './content-type.js'
12
- */
10
+ /** @import { Readable } from 'node:stream' */
11
+ /** @import { ContentType } from './content-type.js' */
13
12
 
14
13
  /**
15
14
  * @typedef {Object} BodyOptions
@@ -32,7 +31,6 @@ export const DEFAULT_BYTE_LIMIT = 1024 * 1024 //
32
31
  * @property { () => Promise<any> } json
33
32
  */
34
33
 
35
-
36
34
  /**
37
35
  * @param {Readable} stream
38
36
  * @param {BodyOptions} [options]
@@ -92,9 +90,9 @@ export function requestBody(stream, options) {
92
90
  // return
93
91
  }
94
92
 
95
-
96
93
  const listener = () => {
97
- controller.error(new Error('Abort Signal Timed out'))
94
+ stats.closed = true
95
+ controller.error(new Error('Abort Signal'))
98
96
  }
99
97
 
100
98
  signal?.addEventListener('abort', listener, { once: true })
@@ -146,6 +144,8 @@ export function requestBody(stream, options) {
146
144
 
147
145
  stream.on('close', () => {
148
146
  // console.log('body reader stream close')
147
+ signal?.removeEventListener('abort', listener)
148
+
149
149
  if(!stats.closed) {
150
150
  stats.closed = true
151
151
  controller.close()
@@ -12,10 +12,12 @@
12
12
 
13
13
  export class CacheControl {
14
14
  /**
15
- * @param {CacheControlOptions} options
15
+ * @param {CacheControlOptions|undefined} options
16
16
  * @returns {string|undefined}
17
17
  */
18
18
  static encode(options) {
19
+ if(options === undefined) { return undefined }
20
+
19
21
  const {
20
22
  pub,
21
23
  priv,
@@ -46,6 +48,9 @@ export class CacheControl {
46
48
  result.push(`stale-if-error=${staleIfError}`)
47
49
  }
48
50
 
51
+ //
52
+ if(result.length === 0) { return undefined }
53
+
49
54
  return result.join(', ')
50
55
  }
51
56
  }
@@ -102,6 +102,31 @@ export class ETag {
102
102
  * @returns {AnyEtagItem}
103
103
  */
104
104
  static any() { return ANY_ETAG_ITEM }
105
+
106
+ /**
107
+ * @param {string|undefined} raw
108
+ * @returns {EtagItem|undefined}
109
+ */
110
+ static parse(raw) {
111
+ if(raw === undefined) { return undefined }
112
+
113
+ const rawEtag = raw.trim()
114
+ const weak = rawEtag.startsWith(CONDITION_ETAG_WEAK_PREFIX)
115
+ const quotedEtag = weak ? rawEtag.substring(CONDITION_ETAG_WEAK_PREFIX.length) : rawEtag
116
+
117
+ if(quotedEtag === CONDITION_ETAG_ANY) { return ANY_ETAG_ITEM }
118
+
119
+ if(!isQuoted(quotedEtag)) { return undefined }
120
+ const etag = stripQuotes(quotedEtag)
121
+ if(!isValidEtag(etag)) { return undefined }
122
+ if(etag === CONDITION_ETAG_ANY) { return undefined }
123
+
124
+ return {
125
+ weak,
126
+ any: false,
127
+ etag
128
+ }
129
+ }
105
130
  }
106
131
 
107
132
  export class Conditional {
@@ -131,40 +156,7 @@ export class Conditional {
131
156
  if(matchHeader === undefined) { return [] }
132
157
 
133
158
  return matchHeader.split(CONDITION_ETAG_SEPARATOR)
134
- .map(etag => etag.trim())
135
- .map(etag => {
136
- if(etag.startsWith(CONDITION_ETAG_WEAK_PREFIX)) {
137
- // weak
138
- return {
139
- weak: true,
140
- etag: etag.substring(CONDITION_ETAG_WEAK_PREFIX.length)
141
- }
142
- }
143
-
144
- // strong
145
- return {
146
- weak: false,
147
- etag
148
- }
149
- })
150
- .map(item => {
151
- if(item.etag === CONDITION_ETAG_ANY) { return ANY_ETAG_ITEM }
152
-
153
- // validated quoted
154
- if(!isQuoted(item.etag)) { return undefined }
155
- const etag = stripQuotes(item.etag)
156
- if(!isValidEtag(etag)) { return undefined }
157
- if(etag === CONDITION_ETAG_ANY) { return undefined }
158
-
159
- /** @type {WeakEtagItem | NotWeakEtagItem} */
160
- const result = {
161
- weak: item.weak,
162
- any: false,
163
- etag
164
- }
165
-
166
- return result
167
- })
159
+ .map(ETag.parse)
168
160
  .filter(item => item !== undefined)
169
161
  }
170
162
 
@@ -0,0 +1,59 @@
1
+ import { RANGE_UNITS_BYTES } from "./response/defs.js"
2
+
3
+ export const CONTENT_RANGE_UNKNOWN = '*'
4
+ export const CONTENT_RANGE_SEPARATOR = '-'
5
+ export const CONTENT_RANGE_SIZE_SEPARATOR = '/'
6
+
7
+ export const DEFAULT_CONTENT_RANGE_DIRECTIVE = {
8
+ units: RANGE_UNITS_BYTES,
9
+ range: CONTENT_RANGE_UNKNOWN,
10
+ size: CONTENT_RANGE_UNKNOWN
11
+ }
12
+ /**
13
+ * @typedef {Object} ContentRangeDirective
14
+ * @property {RANGE_UNITS_BYTES|undefined} [units]
15
+ * @property {{ start: number, end: number }|CONTENT_RANGE_UNKNOWN|undefined} [range]
16
+ * @property {number|CONTENT_RANGE_UNKNOWN|undefined} [size]
17
+ */
18
+
19
+ export class ContentRange {
20
+ /**
21
+ * @param {ContentRangeDirective|undefined} rangeDirective
22
+ * @returns {string|undefined}
23
+ */
24
+ static encode(rangeDirective) {
25
+ if(rangeDirective === undefined) { return undefined }
26
+
27
+ const units = rangeDirective.units ?? DEFAULT_CONTENT_RANGE_DIRECTIVE.units
28
+ const size = rangeDirective.size ?? DEFAULT_CONTENT_RANGE_DIRECTIVE.size
29
+ const range = rangeDirective.range ?? DEFAULT_CONTENT_RANGE_DIRECTIVE.range
30
+
31
+ if(units !== RANGE_UNITS_BYTES) { return undefined }
32
+ if(size !== CONTENT_RANGE_UNKNOWN && !Number.isInteger(size)) { return undefined }
33
+
34
+ if((typeof range === 'string')) {
35
+ if(range !== CONTENT_RANGE_UNKNOWN) { return undefined }
36
+ return `${units} ${CONTENT_RANGE_UNKNOWN}${CONTENT_RANGE_SIZE_SEPARATOR}${size}`
37
+ }
38
+
39
+ const rangeStr = `${range.start}${CONTENT_RANGE_SEPARATOR}${range.end}`
40
+ return `${units} ${rangeStr}${CONTENT_RANGE_SIZE_SEPARATOR}${size}`
41
+ }
42
+ }
43
+
44
+ // console.log(ContentRange.encode({}))
45
+ // console.log(ContentRange.encode({ size: '*' }))
46
+ // console.log(ContentRange.encode({ units: 'bytes' }))
47
+ // console.log(ContentRange.encode({ range: '*' }))
48
+ // console.log(ContentRange.encode({ range: '*', size: '*' }))
49
+
50
+ // console.log()
51
+ // console.log(ContentRange.encode({ range: { start: 0, end: 1024 } }))
52
+ // console.log(ContentRange.encode({ range: { start: 0, end: 1024 }, size: 1024 }))
53
+ // console.log(ContentRange.encode({ range: '*', size: 1024 }))
54
+ // console.log(ContentRange.encode({ size: 1024 }))
55
+
56
+ // console.log()
57
+ // console.log(ContentRange.encode({ units: 'bob' }))
58
+ // console.log(ContentRange.encode({ range: 'bob' }))
59
+ // console.log(ContentRange.encode({ size: 'bob' }))
@@ -5,9 +5,11 @@ export const MIME_TYPE_EVENT_STREAM = 'text/event-stream'
5
5
  export const MIME_TYPE_XML = 'application/xml'
6
6
  export const MIME_TYPE_URL_FORM_DATA = 'application/x-www-form-urlencoded'
7
7
  export const MIME_TYPE_MULTIPART_FORM_DATA = 'multipart/form-data'
8
+ export const MIME_TYPE_MULTIPART_RANGE = 'multipart/byteranges'
8
9
  export const MIME_TYPE_OCTET_STREAM = 'application/octet-stream'
9
10
  export const MIME_TYPE_MESSAGE_HTTP = 'message/http'
10
11
 
12
+
11
13
  export const KNOWN_CONTENT_TYPES = [
12
14
  'application', 'audio', 'image', 'message',
13
15
  'multipart','text', 'video'
package/src/index.js CHANGED
@@ -2,10 +2,13 @@ export * from './accept-encoding.js'
2
2
  export * from './accept-language.js'
3
3
  export * from './accept-util.js'
4
4
  export * from './accept.js'
5
+ export * from './cache-control.js'
5
6
  export * from './conditional.js'
6
7
  export * from './content-disposition.js'
8
+ export * from './content-range.js'
7
9
  export * from './content-type.js'
8
10
  export * from './forwarded.js'
9
11
  export * from './multipart.js'
12
+ export * from './range.js'
10
13
  export * from './rate-limit.js'
11
14
  export * from './server-timing.js'
package/src/multipart.js CHANGED
@@ -1,5 +1,17 @@
1
+ import { ReadableStream } from 'node:stream/web'
2
+
1
3
  import { parseContentDisposition } from './content-disposition.js'
2
4
  import { parseContentType } from './content-type.js'
5
+ import { ContentRange } from './content-range.js'
6
+
7
+ /** @import { ContentRangeDirective } from './content-range.js' */
8
+ /** @import { SendBody } from './response/send-util.js' */
9
+
10
+ /**
11
+ * @typedef {Object} MultipartBytePart
12
+ * @property {SendBody} obj
13
+ * @property {ContentRangeDirective} range
14
+ */
3
15
 
4
16
  export const DISPOSITION_FORM_DATA = 'form-data'
5
17
 
@@ -12,7 +24,8 @@ export const EMPTY = ''
12
24
 
13
25
  export const MULTIPART_HEADER = {
14
26
  CONTENT_DISPOSITION: 'content-disposition',
15
- CONTENT_TYPE: 'content-type'
27
+ CONTENT_TYPE: 'content-type',
28
+ CONTENT_RANGE: 'content-range'
16
29
  }
17
30
 
18
31
  export const MULTIPART_STATE = {
@@ -29,6 +42,15 @@ export class Multipart {
29
42
  * @param {string} [charset='utf8']
30
43
  */
31
44
  static parse(text, boundary, charset = 'utf8') {
45
+ return Multipart.parse_FormData(text, boundary, charset)
46
+ }
47
+
48
+ /**
49
+ * @param {string} text
50
+ * @param {string} boundary
51
+ * @param {string} [charset='utf8']
52
+ */
53
+ static parse_FormData(text, boundary, charset = 'utf8') {
32
54
  // console.log({ boundary, text })
33
55
  const formData = new FormData()
34
56
 
@@ -114,36 +136,62 @@ export class Multipart {
114
136
 
115
137
  return formData
116
138
  }
117
- }
118
139
 
140
+ /**
141
+ * @param {string} contentType
142
+ * @param {Array<MultipartBytePart>} parts
143
+ * @param {number|undefined} contentLength
144
+ * @param {string} boundary
145
+ * @returns {ReadableStream<Uint8Array>}
146
+ */
147
+ static encode_Bytes(contentType, parts, contentLength, boundary) {
148
+ const boundaryBegin = `${BOUNDARY_MARK}${boundary}`
149
+ const boundaryEnd = `${BOUNDARY_MARK}${boundary}${BOUNDARY_MARK}`
150
+
151
+ return new ReadableStream({
152
+ type: 'bytes',
153
+ async start(controller) {
154
+ const encoder = new TextEncoder()
155
+
156
+ for (const part of parts) {
157
+ controller.enqueue(encoder.encode(`${boundaryBegin}${MULTIPART_SEPARATOR}`))
158
+ controller.enqueue(encoder.encode(`${MULTIPART_HEADER.CONTENT_TYPE}: ${contentType}${MULTIPART_SEPARATOR}`))
159
+ controller.enqueue(encoder.encode(`${MULTIPART_HEADER.CONTENT_RANGE}: ${ContentRange.encode({ ...part.range, size: contentLength })}${MULTIPART_SEPARATOR}`))
160
+ controller.enqueue(encoder.encode(MULTIPART_SEPARATOR))
161
+ // controller.enqueue(encoder.encode(MULTIPART_SEPARATOR))
162
+
163
+ if(part.obj instanceof ReadableStream) {
164
+ for await (const chunk of part.obj) {
165
+ if(chunk instanceof ArrayBuffer || ArrayBuffer.isView(chunk)) {
166
+ controller.enqueue(chunk)
167
+ }
168
+ else if(typeof chunk === 'string'){
169
+ controller.enqueue(encoder.encode(chunk))
170
+ }
171
+ else {
172
+ // console.log('chunk type', typeof chunk)
173
+ controller.enqueue(Uint8Array.from([ chunk ]))
174
+ }
175
+ }
176
+ }
177
+ else if(part.obj instanceof ArrayBuffer || ArrayBuffer.isView(part.obj)) {
178
+ controller.enqueue(part.obj)
179
+ }
180
+ else if(typeof part.obj === 'string'){
181
+ controller.enqueue(encoder.encode(part.obj))
182
+ }
183
+ else {
184
+ // console.log('error', typeof part.obj, part.obj)
185
+ throw new Error('unknown part type')
186
+ }
187
+
188
+ controller.enqueue(encoder.encode(MULTIPART_SEPARATOR))
189
+ }
119
190
 
191
+ controller.enqueue(encoder.encode(boundaryEnd))
120
192
 
121
- // const test = '--X-INSOMNIA-BOUNDARY\r\n' +
122
- // 'Content-Disposition: form-data; name="u"\r\n' +
123
- // '\r\n' +
124
- // 'alice\r\n' +
125
- // '--X-INSOMNIA-BOUNDARY\r\n' +
126
- // 'Content-Disposition: form-data; name="user"\r\n' +
127
- // '\r\n' +
128
- // 'jeff\r\n' +
129
- // '--X-INSOMNIA-BOUNDARY--\r\n'
130
- // const result = Multipart.parse(test, 'X-INSOMNIA-BOUNDARY')
131
- // console.log(result)
132
-
133
-
134
- // const test = [
135
- // '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k',
136
- // 'Content-Disposition: form-data; '
137
- // + 'name="upload_file_0"; filename="テスト.dat"',
138
- // 'Content-Type: application/octet-stream',
139
- // '',
140
- // 'A'.repeat(1023),
141
- // '-----------------------------paZqsnEHRufoShdX6fh0lUhXBP4k--'
142
- // ].join('\r\n')
143
- // const result = Multipart.parse(test, '---------------------------paZqsnEHRufoShdX6fh0lUhXBP4k')
144
- // console.log(result)
145
-
146
-
147
- // const test = '--X-INSOMNIA-BOUNDARY--\r\n'
148
- // const result = Multipart.parse(test, 'X-INSOMNIA-BOUNDARY')
149
- // console.log(result)
193
+ controller.close()
194
+ }
195
+ })
196
+ }
197
+ }