Skip to content

Web Streams API: implement ReadableStream + blob.stream() / response.body #237

@TheHypnoo

Description

@TheHypnoo

Summary

Follow-up to #234. Now that Blob has working .arrayBuffer() / .text() / .bytes() / .slice() (PR for #234), the only remaining gap on the WHATWG Blob surface is blob.stream(): ReadableStream<Uint8Array> — and that needs the broader Web Streams API stood up first.

Pre-existing state (verified via grep across crates/):

  • Zero ReadableStream / getReader / ReadableStreamDefaultReader implementations.
  • Response.body is not a stream — js_response_* FFIs in crates/perry-stdlib/src/fetch.rs only emit fully-buffered text / json / arrayBuffer / blob.
  • Request.body likewise only carries the buffered bytes.
  • Symbol.asyncIterator is recognized at HIR (crates/perry-hir/src/lower_decl.rs:77) but no protocol is wired for stream chunk iteration.

A scoped "Blob-only stream" stub would create inconsistency: users would discover blob.stream().getReader() works but response.body.getReader() fails the same way. Better to land Web Streams as a coherent surface.

Surface to land

Minimum viable for parity with Node 22 / browsers:

  • ReadableStream<Uint8Array> constructor + getReader() / cancel() / tee() / pipeTo(dest) / pipeThrough({ readable, writable })
  • ReadableStreamDefaultReader with read(): Promise<{ done, value }>, releaseLock(), closed promise
  • Symbol.asyncIterator protocol so for await (const chunk of stream) works
  • WritableStream + WritableStreamDefaultWriter (needed for pipeTo)
  • TransformStream (needed for pipeThrough)

Once those exist, the consumers fall in:

  • blob.stream() — already declared in response.blob() drops body bytes — same shape as #227, needs Blob instance methods #234's docs/FFIs as deferred; wires to a ReadableStream that emits the body bytes as one chunk (or multiple if we add a chunk-size knob) then done: true.
  • response.body — switch from "always-buffered, never streamed" to a streaming pipe driven from reqwest::Response::chunk().
  • new Request(url, { body: stream }) — accept a ReadableStream as a body source.
  • new Response(stream) — accept a ReadableStream and serialize on read.

Why this needs its own issue

  • ~500-800 LOC of runtime + codegen across multiple crates (perry-runtime, perry-stdlib/fetch, perry-codegen, perry-hir).
  • New GC scanners for the per-stream / per-reader queues (chunks held in the queue must be marked).
  • Backpressure semantics (ReadableStreamDefaultController.desiredSize, queueing strategies) need design work.
  • Async-iteration protocol for streams is its own subsystem that doesn't exist yet — Symbol.asyncIterator resolution + next() / return() shape.

Acceptance criteria

```ts
// blob.stream() round-trip
const blob = new Response("hello world").blob();
const reader = blob.stream().getReader();
const { value, done } = await reader.read(); // value: Uint8Array(b"hello world"), done: false
const r2 = await reader.read(); // r2.done: true

// for-await-of
let total = "";
for await (const chunk of (await new Response("abc").blob()).stream()) {
total += new TextDecoder().decode(chunk);
}
assertEq(total, "abc");

// response.body streaming (proves the API isn't blob-only)
const res = await fetch("https://example.com/big.bin\");
const reader = res.body.getReader();
let bytesSeen = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
bytesSeen += value.length;
}
```

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions