DEV Community

websilvercraft
websilvercraft

Posted on

Same Fetch, Different Streams: duplex: 'half' in Node vs Cloudflare Workers

In the last post What’s the Request object in the browser, Cloudflare, and Node?, I detailed the how the Request class is the same regardless the implementation. However, when handling streams, there are some practical differences, even though both runtimes expose the same Fetch API.

What differs with streams

  • Stream types available

    • Cloudflare Workers: only Web Streams (ReadableStream, WritableStream, TransformStream).
    • Node 18+: has both classic Node streams and Web Streams. fetch uses Web Streams, but Node lets you pass a Node Readable as a request body.
  • Streaming request bodies (uploads)

    • Node: if the request body is a stream, you must set duplex: 'half' in the init/options. If you forget, you’ll get: “duplex option is required when sending a body that is a ReadableStream”.
    • Workers: no duplex flag; just pass a Web ReadableStream.
  • Bridging between stream kinds (Node only)

    • Convert Node → Web: Readable.toWeb(nodeWritable) / Readable.from(nodeReadable) (or Readable.fromWeb).
    • You’ll use this when adapting http.IncomingMessage ↔︎ Fetch.
  • Response bodies (downloads)

    • Both return a Web ReadableStream at response.body.
    • In Node, to send it to http.ServerResponse, convert the Node writable to a Web Writable and pipeTo.

Tiny patterns

Node: adapt IncomingMessageRequest (streaming upload)

import { Readable } from 'node:stream'

const request = new Request(url, {
  method,
  headers,
  body: hasBody ? (req as unknown as Readable) : undefined, // Node stream ok
  // Node/undici requirement for streamed bodies:
  // @ts-expect-error undici extension
  duplex: hasBody ? 'half' : undefined,
})
Enter fullscreen mode Exit fullscreen mode

Node: write a streamed Response back to ServerResponse

import { Readable } from 'node:stream'

const response = await fetchHandler(request)

res.statusCode = response.status
response.headers.forEach((v, k) => res.setHeader(k, v))

if (response.body) {
  // pipe Web ReadableStream → Node ServerResponse
  // @ts-ignore types can lag behind
  await response.body.pipeTo(Readable.toWeb(res))
} else {
  res.end()
}
Enter fullscreen mode Exit fullscreen mode

Workers: streaming is Web-Streams-native

export default {
  async fetch(req: Request) {
    // req.body is a Web ReadableStream (or null)
    return new Response(req.body) // streams through unchanged
  }
}
Enter fullscreen mode Exit fullscreen mode

Rules of thumb

  • Use Web Streams as your common denominator.
  • In Node, add duplex: 'half' only when the request body is a stream.
  • Convert between Node and Web streams only at the edges (the Node HTTP adapter). Your itty router can stay pure-Fetch/Web-Streams and run the same on Workers and Node.

Top comments (0)