10LOC
Node.jsadvanced

Bridge Node streams to web streams

Published July 30, 2027

import { createReadStream } from "node:fs";
import { Readable } from "node:stream";

export const compressedFileResponse = (path: string) => {
  const nodeStream = createReadStream(path, { highWaterMark: 64 * 1024 });
  const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream<Uint8Array>;
  // CompressionStream's .writable is typed WritableStream<BufferSource>, a hair
  // wider than the Uint8Array<ArrayBufferLike> pipeThrough expects -- real runtime
  // non-issue, same strict-mode gap as the ArrayBufferLike/BlobPart mismatch.
  const gzipped = webStream.pipeThrough(new CompressionStream("gzip") as unknown as ReadableWritablePair<Uint8Array>);
  return new Response(gzipped, {
    headers: { "content-type": "application/octet-stream", "content-encoding": "gzip" },
  });
};

What

Readable.toWeb(nodeStream) wraps a Node Readable in a Web Streams API ReadableStream. compressedFileResponse uses that to read a file with node:fs, pipe it through the standard CompressionStream, and hand the result to Response — three pieces that only work together once the Node stream speaks the Web Streams API.

Why it matters

Node's filesystem APIs are Node-stream-native — createReadStream gives you a stream.Readable, not a Web ReadableStream. But a growing set of things only accept the web version: CompressionStream/DecompressionStream, fetch's Response body, and any fetch-style server framework (Next.js Route Handlers, Hono, Deno, Bun.serve) that expects a Response back instead of writing to a raw http.ServerResponse. Readable.toWeb is the adapter between the two worlds — reads on the web side still pull from the same underlying Node stream, so backpressure on one side throttles the other.

How it works

  • createReadStream(path, { highWaterMark: 64 * 1024 }) opens the file as a Node Readable; highWaterMark here controls the Node-side chunk size feeding the conversion.
  • Readable.toWeb(nodeStream) returns a ReadableStream from node:stream/web. The as unknown as ReadableStream<Uint8Array> cast bridges a real typing gap, not a runtime one: @types/node's ReadableStream and lib.dom.d.ts's global ReadableStream are structurally near-identical but nominally different types, so TypeScript won't accept one where the other is expected without a cast.
  • .pipeThrough(new CompressionStream("gzip")) only works on a web ReadableStream — this is the actual reason to bridge. Node's own zlib streams are the right tool for Node-to-Node piping, but CompressionStream is what fetch-based runtimes (and the browser reading the response) speak natively.
  • new Response(gzipped, { ... }) sets the transformed stream directly as the response body. Whatever runtime reads this Response streams it out as it compresses, without ever buffering the whole file in memory.

Gotchas

  • The type cast is necessary, but don't reach for it reflexively — it papers over a real (if small) type mismatch between two ReadableStream declarations. Only cast once you've confirmed the runtime you're targeting treats them interchangeably.
  • Cancelling the resulting web stream — for instance because the client disconnected mid-response — tears down the underlying Node Readable too, closing the file descriptor.
  • Returning a Response is not the same as writing to a raw node:http ServerResponse. This pattern is for fetch-style handlers; a plain http.createServer callback still needs the stream piped to res manually.

Related

  • stream.finished — for observing when the Node-side Readable itself is fully drained or closed, independent of whatever's consuming the web stream on the other end.
  • node:zlib's createGzip() — the Node-stream-native way to compress; reach for it when both ends of the pipe are Node streams and there's no need for a Web ReadableStream at all.