Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions benchmarks/fetch/body-source-shortcut.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { group, bench, run } from 'mitata'
import { Response } from '../../lib/web/fetch/response.js'

// Benchmark body mixin methods with known-source bodies (string & Uint8Array)
// to measure the impact of the body source shortcut optimization (#2164)

const shortString = 'hello world'
const mediumString = 'x'.repeat(1024)
const longString = 'x'.repeat(65536)
const shortBytes = new Uint8Array(32).fill(65)
const mediumBytes = new Uint8Array(1024).fill(65)
const longBytes = new Uint8Array(65536).fill(65)

group('Response#text() with string body', () => {
bench('short string (11B)', async () => {
await new Response(shortString).text()
})
bench('medium string (1KB)', async () => {
await new Response(mediumString).text()
})
bench('long string (64KB)', async () => {
await new Response(longString).text()
})
})

group('Response#json() with string body', () => {
bench('small JSON', async () => {
await new Response('{"a":1}').json()
})
})

group('Response#arrayBuffer() with string body', () => {
bench('short string (11B)', async () => {
await new Response(shortString).arrayBuffer()
})
bench('medium string (1KB)', async () => {
await new Response(mediumString).arrayBuffer()
})
})

group('Response#text() with Uint8Array body', () => {
bench('short bytes (32B)', async () => {
await new Response(shortBytes).text()
})
bench('medium bytes (1KB)', async () => {
await new Response(mediumBytes).text()
})
bench('long bytes (64KB)', async () => {
await new Response(longBytes).text()
})
})

group('Response#arrayBuffer() with Uint8Array body', () => {
bench('short bytes (32B)', async () => {
await new Response(shortBytes).arrayBuffer()
})
bench('medium bytes (1KB)', async () => {
await new Response(mediumBytes).arrayBuffer()
})
})

group('Response#bytes() with Uint8Array body', () => {
bench('short bytes (32B)', async () => {
await new Response(shortBytes).bytes()
})
bench('medium bytes (1KB)', async () => {
await new Response(mediumBytes).bytes()
})
})

await run()
18 changes: 17 additions & 1 deletion lib/web/fetch/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,23 @@ function consumeBody (object, convertBytesToJSValue, instance, getInternalState)
successSteps(Buffer.allocUnsafe(0))
return promise.promise
}

// Optimization: if the body's source is known, skip the web stream
// round-trip and use the source directly. Getting a reader and calling
// read() locks the stream and marks it disturbed so subsequent reads
// correctly reject with "Body is unusable" and bodyUsed returns true.
const { source } = object.body
if (typeof source === 'string') {
const reader = object.body.stream.getReader()
reader.read()
successSteps(Buffer.from(source, 'utf-8'))
return promise.promise
}
if (isUint8Array(source)) {
const reader = object.body.stream.getReader()
reader.read()
successSteps(Buffer.from(source.buffer, source.byteOffset, source.byteLength))
return promise.promise
}
// 6. Otherwise, fully read object’s body given successSteps,
// errorSteps, and object’s relevant global object.
fullyReadBody(object.body, successSteps, errorSteps)
Expand Down
135 changes: 135 additions & 0 deletions test/fetch/body-source-shortcut.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use strict'

const { describe, test } = require('node:test')
const { Response, Request } = require('../../')

// https://github.com/nodejs/undici/issues/2164
describe('body mixin source shortcut', () => {
describe('string source', () => {
test('Response.text() works with string body', async (t) => {
const res = new Response('hello world')
t.assert.strictEqual(await res.text(), 'hello world')
})

test('Response.json() works with string body', async (t) => {
const res = new Response('{"key":"value"}')
t.assert.deepStrictEqual(await res.json(), { key: 'value' })
})

test('Response.arrayBuffer() works with string body', async (t) => {
const res = new Response('abc')
const ab = await res.arrayBuffer()
t.assert.ok(ab instanceof ArrayBuffer)
t.assert.strictEqual(new TextDecoder().decode(ab), 'abc')
})

test('Response.blob() works with string body', async (t) => {
const res = new Response('hello')
const blob = await res.blob()
t.assert.strictEqual(await blob.text(), 'hello')
})

test('Response.bytes() works with string body', async (t) => {
const res = new Response('xyz')
const bytes = await res.bytes()
t.assert.ok(bytes instanceof Uint8Array)
t.assert.strictEqual(new TextDecoder().decode(bytes), 'xyz')
})

test('Request.text() works with string body', async (t) => {
const req = new Request('http://localhost', { method: 'POST', body: 'test' })
t.assert.strictEqual(await req.text(), 'test')
})

test('string body with multibyte characters', async (t) => {
const input = '日本語テスト 🚀'
const res = new Response(input)
t.assert.strictEqual(await res.text(), input)
})

test('empty string body', async (t) => {
const res = new Response('')
t.assert.strictEqual(await res.text(), '')
})
})

describe('Uint8Array source', () => {
test('Response.text() works with Uint8Array body', async (t) => {
const res = new Response(new TextEncoder().encode('binary test'))
t.assert.strictEqual(await res.text(), 'binary test')
})

test('Response.arrayBuffer() works with Uint8Array body', async (t) => {
const input = new Uint8Array([1, 2, 3, 4])
const res = new Response(input)
const ab = await res.arrayBuffer()
t.assert.deepStrictEqual(new Uint8Array(ab), new Uint8Array([1, 2, 3, 4]))
})

test('Response.bytes() works with Uint8Array body', async (t) => {
const input = new Uint8Array([10, 20, 30])
const res = new Response(input)
const bytes = await res.bytes()
t.assert.deepStrictEqual(bytes, new Uint8Array([10, 20, 30]))
})

test('Response.blob() works with Uint8Array body', async (t) => {
const input = new Uint8Array([65, 66, 67]) // ABC
const res = new Response(input)
const blob = await res.blob()
t.assert.strictEqual(await blob.text(), 'ABC')
})
})

describe('body is marked unusable after consumption', () => {
test('string body cannot be read twice', async (t) => {
const res = new Response('once')
await res.text()
await t.assert.rejects(res.text(), TypeError)
})

test('Uint8Array body cannot be read twice', async (t) => {
const res = new Response(new Uint8Array([1, 2, 3]))
await res.arrayBuffer()
await t.assert.rejects(res.arrayBuffer(), TypeError)
})

test('bodyUsed is true after consuming string body', async (t) => {
const res = new Response('used')
t.assert.strictEqual(res.bodyUsed, false)
await res.text()
t.assert.strictEqual(res.bodyUsed, true)
})

test('bodyUsed is true after consuming Uint8Array body', async (t) => {
const res = new Response(new Uint8Array([1]))
t.assert.strictEqual(res.bodyUsed, false)
await res.bytes()
t.assert.strictEqual(res.bodyUsed, true)
})
})

describe('non-shortcuttable sources still work', () => {
test('ReadableStream body still works', async (t) => {
const stream = new ReadableStream({
start (controller) {
controller.enqueue(new TextEncoder().encode('streamed'))
controller.close()
}
})
const res = new Response(stream)
t.assert.strictEqual(await res.text(), 'streamed')
})

test('Blob body still works', async (t) => {
const blob = new Blob(['blob content'])
const res = new Response(blob)
t.assert.strictEqual(await res.text(), 'blob content')
})

test('null body returns empty string', async (t) => {
const res = new Response(null)
t.assert.strictEqual(await res.text(), '')
})
})
})
Loading