From 792b7be108fcdbb652afaf2de0d16de148743e7a Mon Sep 17 00:00:00 2001 From: Roberto Bianchi Date: Thu, 9 Apr 2026 15:56:03 +0200 Subject: [PATCH] feat(streams): limit usage in body mixin #2164 Signed-off-by: Roberto Bianchi --- benchmarks/fetch/body-source-shortcut.mjs | 71 ++++++++++++ lib/web/fetch/body.js | 18 ++- test/fetch/body-source-shortcut.js | 135 ++++++++++++++++++++++ 3 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 benchmarks/fetch/body-source-shortcut.mjs create mode 100644 test/fetch/body-source-shortcut.js diff --git a/benchmarks/fetch/body-source-shortcut.mjs b/benchmarks/fetch/body-source-shortcut.mjs new file mode 100644 index 00000000000..bd63976129b --- /dev/null +++ b/benchmarks/fetch/body-source-shortcut.mjs @@ -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() diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js index 7e08b29fd6c..89aa694e9c4 100644 --- a/lib/web/fetch/body.js +++ b/lib/web/fetch/body.js @@ -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) diff --git a/test/fetch/body-source-shortcut.js b/test/fetch/body-source-shortcut.js new file mode 100644 index 00000000000..d8cfc23100b --- /dev/null +++ b/test/fetch/body-source-shortcut.js @@ -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(), '') + }) + }) +})