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 NodeReadable;highWaterMarkhere controls the Node-side chunk size feeding the conversion.Readable.toWeb(nodeStream)returns aReadableStreamfromnode:stream/web. Theas unknown as ReadableStream<Uint8Array>cast bridges a real typing gap, not a runtime one:@types/node'sReadableStreamandlib.dom.d.ts's globalReadableStreamare 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 webReadableStream— this is the actual reason to bridge. Node's ownzlibstreams are the right tool for Node-to-Node piping, butCompressionStreamis 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 thisResponsestreams 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
ReadableStreamdeclarations. 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
Readabletoo, closing the file descriptor. - Returning a
Responseis not the same as writing to a rawnode:httpServerResponse. This pattern is for fetch-style handlers; a plainhttp.createServercallback still needs the stream piped toresmanually.
Related
stream.finished— for observing when the Node-sideReadableitself is fully drained or closed, independent of whatever's consuming the web stream on the other end.node:zlib'screateGzip()— 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 WebReadableStreamat all.