Skip to content

Commit 23fbc08

Browse files
authored
feat: implement spec-compliant body mixins (nodejs#1694)
* feat: implement spec-compliant body mixins * fix: skip tests on v16.8
1 parent d7c74f7 commit 23fbc08

File tree

9 files changed

+203
-123
lines changed

9 files changed

+203
-123
lines changed

lib/fetch/body.js

Lines changed: 184 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ const assert = require('assert')
1313
const { isErrored } = require('../core/util')
1414
const { isUint8Array, isArrayBuffer } = require('util/types')
1515
const { File } = require('./file')
16+
const { StringDecoder } = require('string_decoder')
17+
const { parseMIMEType } = require('./dataURL')
1618

1719
let ReadableStream
1820

@@ -301,117 +303,28 @@ function throwIfAborted (state) {
301303

302304
function bodyMixinMethods (instance) {
303305
const methods = {
304-
async blob () {
305-
if (!(this instanceof instance)) {
306-
throw new TypeError('Illegal invocation')
307-
}
308-
309-
throwIfAborted(this[kState])
310-
311-
const chunks = []
312-
313-
for await (const chunk of consumeBody(this[kState].body)) {
314-
if (!isUint8Array(chunk)) {
315-
throw new TypeError('Expected Uint8Array chunk')
316-
}
317-
318-
// Assemble one final large blob with Uint8Array's can exhaust memory.
319-
// That's why we create create multiple blob's and using references
320-
chunks.push(new Blob([chunk]))
321-
}
322-
323-
return new Blob(chunks, { type: this.headers.get('Content-Type') || '' })
306+
blob () {
307+
// The blob() method steps are to return the result of
308+
// running consume body with this and Blob.
309+
return specConsumeBody(this, 'Blob', instance)
324310
},
325311

326-
async arrayBuffer () {
327-
if (!(this instanceof instance)) {
328-
throw new TypeError('Illegal invocation')
329-
}
330-
331-
throwIfAborted(this[kState])
332-
333-
const contentLength = this.headers.get('content-length')
334-
const encoded = this.headers.has('content-encoding')
335-
336-
// if we have content length and no encoding, then we can
337-
// pre allocate the buffer and just read the data into it
338-
if (!encoded && contentLength) {
339-
const buffer = new Uint8Array(contentLength)
340-
let offset = 0
341-
342-
for await (const chunk of consumeBody(this[kState].body)) {
343-
if (!isUint8Array(chunk)) {
344-
throw new TypeError('Expected Uint8Array chunk')
345-
}
346-
347-
buffer.set(chunk, offset)
348-
offset += chunk.length
349-
}
350-
351-
return buffer.buffer
352-
}
353-
354-
// if we don't have content length, then we have to allocate 2x the
355-
// size of the body, once for consumed data, and once for the final buffer
356-
357-
// This could be optimized by using growable ArrayBuffer, but it's not
358-
// implemented yet. https://github.com/tc39/proposal-resizablearraybuffer
359-
360-
const chunks = []
361-
let size = 0
362-
363-
for await (const chunk of consumeBody(this[kState].body)) {
364-
if (!isUint8Array(chunk)) {
365-
throw new TypeError('Expected Uint8Array chunk')
366-
}
367-
368-
chunks.push(chunk)
369-
size += chunk.byteLength
370-
}
371-
372-
const buffer = new Uint8Array(size)
373-
let offset = 0
374-
375-
for (const chunk of chunks) {
376-
buffer.set(chunk, offset)
377-
offset += chunk.byteLength
378-
}
379-
380-
return buffer.buffer
312+
arrayBuffer () {
313+
// The arrayBuffer() method steps are to return the
314+
// result of running consume body with this and ArrayBuffer.
315+
return specConsumeBody(this, 'ArrayBuffer', instance)
381316
},
382317

383-
async text () {
384-
if (!(this instanceof instance)) {
385-
throw new TypeError('Illegal invocation')
386-
}
387-
388-
throwIfAborted(this[kState])
389-
390-
let result = ''
391-
const textDecoder = new TextDecoder()
392-
393-
for await (const chunk of consumeBody(this[kState].body)) {
394-
if (!isUint8Array(chunk)) {
395-
throw new TypeError('Expected Uint8Array chunk')
396-
}
397-
398-
result += textDecoder.decode(chunk, { stream: true })
399-
}
400-
401-
// flush
402-
result += textDecoder.decode()
403-
404-
return result
318+
text () {
319+
// The text() method steps are to return the result of
320+
// running consume body with this and text.
321+
return specConsumeBody(this, 'text', instance)
405322
},
406323

407-
async json () {
408-
if (!(this instanceof instance)) {
409-
throw new TypeError('Illegal invocation')
410-
}
411-
412-
throwIfAborted(this[kState])
413-
414-
return JSON.parse(await this.text())
324+
json () {
325+
// The json() method steps are to return the result of
326+
// running consume body with this and JSON.
327+
return specConsumeBody(this, 'JSON', instance)
415328
},
416329

417330
async formData () {
@@ -534,6 +447,172 @@ function mixinBody (prototype) {
534447
Object.assign(prototype.prototype, bodyMixinMethods(prototype))
535448
}
536449

450+
// https://fetch.spec.whatwg.org/#concept-body-consume-body
451+
async function specConsumeBody (object, type, instance) {
452+
if (!(object instanceof instance)) {
453+
throw new TypeError('Illegal invocation')
454+
}
455+
456+
// TODO: why is this needed?
457+
throwIfAborted(object[kState])
458+
459+
// 1. If object is unusable, then return a promise rejected
460+
// with a TypeError.
461+
if (bodyUnusable(object[kState].body)) {
462+
throw new TypeError('Body is unusable')
463+
}
464+
465+
// 2. Let promise be a promise resolved with an empty byte
466+
// sequence.
467+
let promise
468+
469+
// 3. If object’s body is non-null, then set promise to the
470+
// result of fully reading body as promise given object’s
471+
// body.
472+
if (object[kState].body != null) {
473+
promise = await fullyReadBodyAsPromise(object[kState].body)
474+
} else {
475+
// step #2
476+
promise = { size: 0, bytes: [new Uint8Array()] }
477+
}
478+
479+
// 4. Let steps be to return the result of package data with
480+
// the first argument given, type, and object’s MIME type.
481+
const mimeType = type === 'Blob' || type === 'FormData'
482+
? bodyMimeType(object)
483+
: undefined
484+
485+
// 5. Return the result of upon fulfillment of promise given
486+
// steps.
487+
return packageData(promise, type, mimeType)
488+
}
489+
490+
/**
491+
* @see https://fetch.spec.whatwg.org/#concept-body-package-data
492+
* @param {{ size: number, bytes: Uint8Array[] }} bytes
493+
* @param {string} type
494+
* @param {ReturnType<typeof parseMIMEType>|undefined} mimeType
495+
*/
496+
function packageData ({ bytes, size }, type, mimeType) {
497+
switch (type) {
498+
case 'ArrayBuffer': {
499+
// Return a new ArrayBuffer whose contents are bytes.
500+
const uint8 = new Uint8Array(size)
501+
let offset = 0
502+
503+
for (const chunk of bytes) {
504+
uint8.set(chunk, offset)
505+
offset += chunk.byteLength
506+
}
507+
508+
return uint8.buffer
509+
}
510+
case 'Blob': {
511+
// Return a Blob whose contents are bytes and type attribute
512+
// is mimeType.
513+
return new Blob(bytes, { type: mimeType?.essence })
514+
}
515+
case 'JSON': {
516+
// Return the result of running parse JSON from bytes on bytes.
517+
return JSON.parse(utf8DecodeBytes(bytes))
518+
}
519+
case 'text': {
520+
// 1. Return the result of running UTF-8 decode on bytes.
521+
return utf8DecodeBytes(bytes)
522+
}
523+
}
524+
}
525+
526+
// https://fetch.spec.whatwg.org/#body-unusable
527+
function bodyUnusable (body) {
528+
// An object including the Body interface mixin is
529+
// said to be unusable if its body is non-null and
530+
// its body’s stream is disturbed or locked.
531+
return body != null && (body.stream.locked || util.isDisturbed(body.stream))
532+
}
533+
534+
// https://fetch.spec.whatwg.org/#fully-reading-body-as-promise
535+
async function fullyReadBodyAsPromise (body) {
536+
// 1. Let reader be the result of getting a reader for body’s
537+
// stream. If that threw an exception, then return a promise
538+
// rejected with that exception.
539+
const reader = body.stream.getReader()
540+
541+
// 2. Return the result of reading all bytes from reader.
542+
/** @type {Uint8Array[]} */
543+
const bytes = []
544+
let size = 0
545+
546+
while (true) {
547+
const { done, value } = await reader.read()
548+
549+
if (done) {
550+
break
551+
}
552+
553+
// https://streams.spec.whatwg.org/#read-loop
554+
// If chunk is not a Uint8Array object, reject promise with
555+
// a TypeError and abort these steps.
556+
if (!isUint8Array(value)) {
557+
throw new TypeError('Value is not a Uint8Array.')
558+
}
559+
560+
bytes.push(value)
561+
size += value.byteLength
562+
}
563+
564+
return { size, bytes }
565+
}
566+
567+
/**
568+
* @see https://encoding.spec.whatwg.org/#utf-8-decode
569+
* @param {Uint8Array[]} ioQueue
570+
*/
571+
function utf8DecodeBytes (ioQueue) {
572+
if (ioQueue.length === 0) {
573+
return ''
574+
}
575+
576+
// 1. Let buffer be the result of peeking three bytes
577+
// from ioQueue, converted to a byte sequence.
578+
const buffer = ioQueue[0]
579+
580+
// 2. If buffer is 0xEF 0xBB 0xBF, then read three
581+
// bytes from ioQueue. (Do nothing with those bytes.)
582+
if (buffer[0] === 0xEF && buffer[1] === 0xBB && buffer[2] === 0xBF) {
583+
ioQueue[0] = ioQueue[0].subarray(3)
584+
}
585+
586+
// 3. Process a queue with an instance of UTF-8’s
587+
// decoder, ioQueue, output, and "replacement".
588+
const decoder = new StringDecoder('utf-8')
589+
let output = ''
590+
591+
for (const chunk of ioQueue) {
592+
output += decoder.write(chunk)
593+
}
594+
595+
output += decoder.end()
596+
597+
// 4. Return output.
598+
return output
599+
}
600+
601+
/**
602+
* @see https://fetch.spec.whatwg.org/#concept-body-mime-type
603+
* @param {import('./response').Response|import('./request').Request} object
604+
*/
605+
function bodyMimeType (object) {
606+
const { headersList } = object[kState]
607+
const contentType = headersList.get('content-type')
608+
609+
if (contentType === null) {
610+
return 'failure'
611+
}
612+
613+
return parseMIMEType(contentType)
614+
}
615+
537616
module.exports = {
538617
extractBody,
539618
safelyExtractBody,

lib/fetch/dataURL.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,11 @@ function parseMIMEType (input) {
321321
type: type.toLowerCase(),
322322
subtype: subtype.toLowerCase(),
323323
/** @type {Map<string, string>} */
324-
parameters: new Map()
324+
parameters: new Map(),
325+
// https://mimesniff.spec.whatwg.org/#mime-type-essence
326+
get essence () {
327+
return `${this.type}/${this.subtype}`
328+
}
325329
}
326330

327331
// 11. While position is not past the end of input:

test/fetch/client-fetch.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ test('locked blob body', (t) => {
316316
const res = await fetch(`http://localhost:${server.address().port}`)
317317
const reader = res.body.getReader()
318318
res.blob().catch(err => {
319-
t.equal(err.message, 'The stream is locked.')
319+
t.equal(err.message, 'Body is unusable')
320320
reader.cancel()
321321
})
322322
})
@@ -336,7 +336,7 @@ test('disturbed blob body', (t) => {
336336
t.pass(2)
337337
})
338338
res.blob().catch(err => {
339-
t.equal(err.message, 'The body has already been consumed.')
339+
t.equal(err.message, 'Body is unusable')
340340
})
341341
})
342342
})

test/fetch/data-uri.js

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,19 +113,22 @@ test('https://mimesniff.spec.whatwg.org/#parse-a-mime-type', (t) => {
113113
t.same(parseMIMEType('text/plain'), {
114114
type: 'text',
115115
subtype: 'plain',
116-
parameters: new Map()
116+
parameters: new Map(),
117+
essence: 'text/plain'
117118
})
118119

119120
t.same(parseMIMEType('text/html;charset="shift_jis"iso-2022-jp'), {
120121
type: 'text',
121122
subtype: 'html',
122-
parameters: new Map([['charset', 'shift_jis']])
123+
parameters: new Map([['charset', 'shift_jis']]),
124+
essence: 'text/html'
123125
})
124126

125127
t.same(parseMIMEType('application/javascript'), {
126128
type: 'application',
127129
subtype: 'javascript',
128-
parameters: new Map()
130+
parameters: new Map(),
131+
essence: 'application/javascript'
129132
})
130133

131134
t.end()

test/fetch/response.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ test('Modifying headers using Headers.prototype.set', (t) => {
171171
})
172172

173173
// https://github.com/nodejs/node/issues/43838
174-
test('constructing a Response with a ReadableStream body', async (t) => {
174+
test('constructing a Response with a ReadableStream body', { skip: process.version.startsWith('v16.') }, async (t) => {
175175
const text = '{"foo":"bar"}'
176176
const uint8 = new TextEncoder().encode(text)
177177

@@ -209,7 +209,7 @@ test('constructing a Response with a ReadableStream body', async (t) => {
209209
t.end()
210210
})
211211

212-
t.test('Readable with ArrayBuffer chunk still throws', async (t) => {
212+
t.test('Readable with ArrayBuffer chunk still throws', { skip: process.version.startsWith('v16.') }, async (t) => {
213213
const readable = new ReadableStream({
214214
start (controller) {
215215
controller.enqueue(uint8.buffer)

0 commit comments

Comments
 (0)