Repository: nodejs/node (JavaScript)

Issue:
byob reader support for Blob.stream()
### Version

all

### Platform

_No response_

### Subsystem

_No response_

### What steps will reproduce the bug?

```js
new Blob([1,2]).stream().getReader({mode: 'byob'})
```

### How often does it reproduce? Is there a required condition?

every time

### What is the expected behavior? Why is that the expected behavior?

get a byob reader back

### What do you see instead?

```
Uncaught:
TypeError [ERR_INVALID_ARG_VALUE]: The argument 'stream' must be a byte stream. Received ReadableStream { locked: false, state: 'readable', supportsBYOB: false }
    at __node_internal_captureLargerStackTrace (node:internal/errors:490:5)
    at new NodeError (node:internal/errors:399:5)
    at setupReadableStreamBYOBReader (node:internal/webstreams/readablestream:2149:11)
    at new ReadableStreamBYOBReader (node:internal/webstreams/readablestream:912:5)
    at ReadableStream.getReader (node:internal/webstreams/readablestream:350:12) {
  code: 'ERR_INVALID_ARG_VALUE'
}
```

### Additional information

Works in Firefox, Deno

- https://github.com/w3c/FileAPI/pull/188
- https://github.com/w3c/FileAPI/pull/186


Discussion: Hey! so I was trying to look around to solve this issue and while writing tests for it I noticed a weird behavior of node in comparison to browsers:

take the following script:
```mjs
async function fn() {
  const b = new Blob([1, 2, 3, 4, 5, 5, 6, 6]).stream().getReader();

  const chunks = [];

  while (true) {
    const { done, value } = await b.read();
    if (done) break;
    chunks.push(value);
    console.log('read value', value);
  }

  console.log(chunks);
}

await fn();
```

If executed in browsers (tested on chrome and firefox) the output looks like this:

<img width="598" alt="Screenshot 2023-05-14 at 7 00 26 PM" src="https://github.com/nodejs/node/assets/20257253/7127836d-8c2e-4278-a1fc-47a154f7518d">


Whereas when trying this with node v20.1.0

```console
debadreechatterjee@Debadree-MacBook-Pro node % node test.mjs
read value Uint8Array(1) [ 49 ]
debadreechatterjee@Debadree-MacBook-Pro node %
```

it appears the promise hangs which leads to the process exiting, is this a bug or is my script something wrong
Ok interestingly if we call ```blob.arrayBuffer``` before creating the stream this works fine (edit: this also seems flakey)
Sounds like a bug cc @jasnell 

The fix for this issue should be as simple as setting `type: 'bytes'` here https://github.com/nodejs/node/blob/9f3aacbc27ac3f377d225ecd51aecb0f3de12566/lib/internal/blob.js#L323
Not really we have to change the way we respond too no? I was trying out something like this

I was trying something like this
```diff
diff --git a/lib/internal/blob.js b/lib/internal/blob.js
index ee8e1c7581..47face7a93 100644
--- a/lib/internal/blob.js
+++ b/lib/internal/blob.js
@@ -321,6 +321,7 @@ class Blob {
 
     const reader = this[kHandle].getReader();
     return new lazyReadableStream({
+      type: 'bytes',
       start(c) {
         // There really should only be one read at a time so using an
         // array here is purely defensive.
@@ -353,7 +354,20 @@ class Blob {
             return;
           }
           if (buffer !== undefined) {
-            c.enqueue(new Uint8Array(buffer));
+            if (c.byobRequest) {
+              const byobRequestView = c.byobRequest.view;
+              const byteLength = buffer.byteLength;
+              const viewByteLength = byobRequestView.byteLength;
+              const viewByteOffset = byobRequestView.byteOffset;
+              for (let i = 0; i < byteLength;) {
+                const chunk = new Uint8Array(buffer.slice(i, i + viewByteLength));
+                byobRequestView.set(chunk, viewByteOffset);
+                i += viewByteLength;
+                c.byobRequest.respond(viewByteLength);
+              }
+            } else {
+              c.enqueue(new Uint8Array(buffer));
+            }
           }
           pending.resolve();
         });
```
but this causes some error with detached array buffers which i dont yet understand 😅
When you enqueue a uint8array then the underlying ArrayBuffer gets detached. 
And you can no longer use that ArrayBuffer for anything else.

```js
ab = new Uint8Array([97, 98]).buffer

chunk1 = new Uint8Array(ab, 0, 1)
chunk2 = new Uint8Array(ab, 1, 1)

rs = new ReadableStream({
  type: 'bytes',
  start(ctrl) {
    console.log(chunk1.byteLength, ab.byteLength) // 1, 2
    ctrl.enqueue(chunk1)
    console.log(chunk1.byteLength, ab.byteLength) // 0, 0
    
    // if you now try to enqueue chunk2 then it will fail
    // ctrl.enqueue(chunk2)
    ctrl.close()
  }
})

into = new Uint8Array(2)
rs.getReader({mode: 'byob'}).read(into)
```
It's as if you have transfered the arraybuffer to another thread using `postMessage(ab, [ ab ])`
the arraybuffer byteLengths gets set to 0
Ah understood so what could be a work around for the byob mode? `respondWithNewView` maybe?
Haven't investigated the source code so much. 
but i had one issue in earlier NodeJS version where it first read everything as a hole arrayBuffer 
and then the ReadableStream would just slice them into smaller views with subarray so it was totally unexpected when i receive a byteOffset other then 0
and that each `chunk.buffer` where the same instance.
that was abnormal

so if you are for some reason reusing the same ArrayBuffer twice then that might be an issue.

anyhow. i'm not sure if you should enqueue a uint8array anyway. i suppose you are going to use `controller.byobRequest.respond(bytesRead)`

the stream spec have a good turtorial on how to use files + byte streams here
https://streams.spec.whatwg.org/#example-rbs-pull

```js
const fs = require("fs").promises;
const DEFAULT_CHUNK_SIZE = 1024;

function makeReadableByteFileStream(filename) {
 let fileHandle;
 let position = 0;

  return new ReadableStream({
    type: "bytes",

    async start() {
      fileHandle = await fs.open(filename, "r");
    },

    async pull(controller) {
      // Even when the consumer is using the default reader, the auto-allocation
      // feature allocates a buffer and passes it to us via byobRequest.
      const v = controller.byobRequest.view;

      const { bytesRead } = await fileHandle.read(v, 0, v.byteLength, position);
      if (bytesRead === 0) {
        await fileHandle.close();
        controller.close();
        controller.byobRequest.respond(0);
      } else {
        position += bytesRead;
        controller.byobRequest.respond(bytesRead);
      }
    },

    cancel() {
      return fileHandle.close();
    },

    autoAllocateChunkSize: DEFAULT_CHUNK_SIZE
  });
}
```
Ah got it! i think responding multiple times by slicing the array buffer wont work, i think this would need changes on c++ side too, trying to investigate thank you for the inputs!!
I don't see why slicing the arraybuffer would not work. you are creating a copy after all.  but doing so this way would probably also indicate that you probably are also are doing something wrong in the first place... it would be better to read into an existing arraybuffer view instead
Oh ok @KhafraDev was right it seems to be as simple as adding type: 'bytes' heh I went of on unnecessary tangent but it seems the promise hang issue is what needs to be looked into
> Oh ok @KhafraDev was right it seems to be as simple as adding type: 'bytes' heh I went of on unnecessary tangent but it seems the promise hang issue is what needs to be looked into

i tough so too. 
i don't know how the stream spec works exactly. but maybe if you `ctrl.enqueue()` something in a bytes stream
then the stream will do the `controller.byobRequest.respond(x)` automatically for you (internally). as well as setting the `byobRequestView` to what you enqueued.
Hey so I opened a PR to fix the hanging issue I pointed out in https://github.com/nodejs/node/issues/47993#issuecomment-1546901936 here https://github.com/nodejs/node/pull/48232 but as for adding 'bytes' support there seems like we have another issue https://github.com/nodejs/node/issues/48233
Been able to figure out how to get the streams working and opened a draft at https://github.com/nodejs/node/pull/49713 but this breaks an unlikely wpt 

```console
[UNEXPECTED_FAILURE][FAIL] Blob empty
promise_test: Unhandled rejection with value: object "TypeError [ERR_INVALID_STATE]: Invalid state: chunk ArrayBuffer is zero-length or detached"
    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
Command: /Users/debadreechatterjee/Documents/personal/node/out/Release/node  /Users/debadreechatterjee/Documents/personal/node/test/wpt/test-structured-clone.js 'structured-clone.any.js'
```
from wpt/test-structured-clone

anyone have any ideas?




Fix (patch):
diff --git a/lib/internal/blob.js b/lib/internal/blob.js
index c5b417ddc291b6..a54adb615fbc17 100644
--- a/lib/internal/blob.js
+++ b/lib/internal/blob.js
@@ -320,6 +320,7 @@ class Blob {
 
     const reader = this[kHandle].getReader();
     return new lazyReadableStream({
+      type: 'bytes',
       start(c) {
         // There really should only be one read at a time so using an
         // array here is purely defensive.
@@ -339,6 +340,9 @@ class Blob {
             if (status === 0) {
               // EOS
               c.close();
+              // This is to signal the end for byob readers
+              // see https://streams.spec.whatwg.org/#example-rbs-pull
+              c.byobRequest?.respond(0);
               const pending = this.pendingPulls.shift();
               pending.resolve();
               return;
@@ -352,13 +356,15 @@ class Blob {
               pending.reject(error);
               return;
             }
-            if (buffer !== undefined) {
+            // ReadableByteStreamController.enqueue errors if we submit a 0-length
+            // buffer. We need to check for that here.
+            if (buffer !== undefined && buffer.byteLength !== 0) {
               c.enqueue(new Uint8Array(buffer));
             }
             // We keep reading until we either reach EOS, some error, or we
             // hit the flow rate of the stream (c.desiredSize).
             queueMicrotask(() => {
-              if (c.desiredSize <= 0) {
+              if (c.desiredSize < 0) {
                 // A manual backpressure check.
                 if (this.pendingPulls.length !== 0) {
                   // A case of waiting pull finished (= not yet canceled)