cloneable-readable
cloneable-readable wraps one Node.js Readable and lets several consumers receive the complete stream, including consumers attached on later event-loop turns. You create every clone before reading starts, then pipe, resume, or attach data/readable listeners to all branches. The wrapper waits until every declared branch is flowing, preserves object mode, and forwards source errors to the wrapper and clones. It solves a narrow fan-out problem for one-shot streams such as file uploads, request bodies, hashing, and duplicate file writes; it is not a general message bus or a replayable cache.
A useful, focused fix when several known consumers must receive one Node Readable from byte zero, especially when attachment is slightly delayed. Do not install it for dynamic subscribers, independent object copies, or any design where one branch may remain idle.
Use it if
- One non-rewindable Node Readable must feed two or more consumers and every consumer needs every chunk from the beginning
- Some branches attach a tick or timer later, so piping the source directly to multiple PassThrough streams would risk an early start
- You need the same fan-out behavior for object-mode streams without configuring each branch by hand
- You are maintaining CommonJS stream code already built around readable-stream and Node's pipe or pipeline APIs
- You can reopen or regenerate the input independently for each consumer; separate streams isolate failures and backpressure more cleanly
- You do not know every branch before consumption begins: clone() throws 'already started' after the wrapper starts reading the source
- One consumer may never start; the README says all clones must be piped, resumed, or listened to, and one idle clone deliberately stalls every branch
- You need independent object copies: object-mode branches receive the same object references, so mutation in one consumer is visible to the others
- You want first-party TypeScript declarations or ESM exports; version 3.0.0 ships one CommonJS index.js and no declaration file
- Your slowest branch is untrusted or unbounded; fan-out keeps all consumers coordinated through stream backpressure, so one lagging destination can hold up the source
Setup reality
Installation is one command, npm install cloneable-readable, with readable-stream 4 as its only runtime dependency. There are no native builds, credentials, environment variables, or config files. The setup trap is lifecycle order. Wrap the source, call clone() for every extra branch before any branch is piped, resumed, or given a data/readable listener, and then make every declared branch flow. The implementation counts the wrapper itself as a branch and does not pipe the original source until that count reaches zero. Forgetting one clone does not merely drop that output; it prevents all output. If a branch is no longer needed, destroy that clone so the other branches can proceed. Once flow has begun, clone() throws 'already started', so this is not late subscriber replay. Source errors are forwarded to the wrapper and clones, but destroying the wrapper or an individual clone does not destroy the original source; decide who owns source cleanup and prefer pipeline() for destination error handling. Object mode is copied from the source automatically, but chunk objects are shared references rather than deep clones. TypeScript users need separate declarations such as @types/cloneable-readable and may have to enable esModuleInterop or use require syntax. Version 3 is CommonJS and depends on readable-stream 4, so check that combination if the surrounding application standardizes on Node core streams or ESM-only modules.
Patterns
Send one file stream to two filesduplicate-file-read
const fs = require('node:fs')
const { pipeline } = require('node:stream/promises')
const cloneable = require('cloneable-readable')
const source = cloneable(fs.createReadStream('input.bin'))
const copy = source.clone()
await Promise.all([
pipeline(source, fs.createWriteStream('copy-a.bin')),
pipeline(copy, fs.createWriteStream('copy-b.bin')),
])Create copy before either pipeline starts. Calling source.clone() after flow begins throws 'already started'.
Declare every branch before startingcreate-multiple-clones
const cloneable = require('cloneable-readable')
const shared = cloneable(incomingRequest)
const forParser = shared.clone()
const forArchive = shared.clone()
parser.consume(forParser)
archive.write(forArchive)
shared.resume()The wrapper itself is also a branch. If it has no useful consumer, resume it to drain it after the clones are connected.
Attach a clone on a later tickdelay-one-consumer
const shared = cloneable(source)
const delayed = shared.clone()
shared.pipe(primarySink)
setImmediate(() => {
delayed.pipe(secondarySink)
})The original source waits for delayed because that clone was declared first. An undeclared late subscriber cannot be added after reading starts.
Destroy a branch that will not be useddiscard-unused-clone
const shared = cloneable(source)
const optional = shared.clone()
shared.pipe(requiredSink)
if (shouldSaveCopy) {
optional.pipe(copySink)
} else {
optional.destroy()
}Leaving optional untouched stalls the source. Destroying one clone lets the remaining branches proceed and does not destroy the source.
Read one branch with data eventsconsume-data-events
const shared = cloneable(source)
const observer = shared.clone()
let bytes = 0
observer.on('data', (chunk) => {
bytes += chunk.length
})
observer.on('end', () => console.log({ bytes }))
shared.pipe(destination)Adding a data listener counts as starting that branch. Attach every clone's consumer before expecting the source to flow.
Pull chunks with the readable eventconsume-readable-events
const shared = cloneable(source)
const copy = shared.clone()
copy.on('readable', function () {
let chunk
while ((chunk = this.read()) !== null) {
inspectChunk(chunk)
}
})
shared.resume()A readable listener also marks the clone as active. The main wrapper is resumed because this example does not otherwise consume it.
Clone an object-mode streamfan-out-object-mode
const { Readable } = require('node:stream')
const cloneable = require('cloneable-readable')
const shared = cloneable(Readable.from([
{ id: 1, state: 'new' },
{ id: 2, state: 'ready' },
], { objectMode: true }))
const auditBranch = shared.clone()
shared.on('data', processRecord)
auditBranch.on('data', auditRecord)Object mode is detected automatically, but both branches receive the same object references. Do not mutate records in either consumer.
Create another branch from a cloneclone-from-clone
const shared = cloneable(source)
const first = shared.clone()
const second = first.clone()
shared.pipe(sinkA)
first.pipe(sinkB)
second.pipe(sinkC)A clone delegates clone() to the same parent, so all three branches still share one start barrier and one source.
Wrap a stream only when neededavoid-double-wrapping
const cloneable = require('cloneable-readable')
function ensureCloneable(stream) {
return cloneable.isCloneable(stream) ? stream : cloneable(stream)
}
const shared = ensureCloneable(input)
const copy = shared.clone()isCloneable() recognizes both the main wrapper and clones created by it. It does not claim that an arbitrary stream can be replayed.
Hash while sending the same bytes elsewherehash-and-upload
const { createHash } = require('node:crypto')
const { pipeline } = require('node:stream/promises')
const cloneable = require('cloneable-readable')
const shared = cloneable(uploadBody)
const hashBranch = shared.clone()
const hash = createHash('sha256').setEncoding('hex')
await Promise.all([
pipeline(shared, objectStoreSink),
pipeline(hashBranch, hash),
])
console.log(hash.read())The slower pipeline governs overall progress through backpressure, so hashing does not turn the upload body into an unbounded in-memory copy.
Observe a source failure on every branchhandle-source-error
const shared = cloneable(source)
const copy = shared.clone()
shared.on('error', (err) => report('primary', err))
copy.on('error', (err) => report('copy', err))
shared.pipe(sinkA)
copy.pipe(sinkB)The implementation forwards a source error to the wrapper, which then reaches clones. Every unpiped stream still needs an error listener or pipeline owner.
Let pipeline clean up destinationsuse-promise-pipeline
const { pipeline } = require('node:stream/promises')
const cloneable = require('cloneable-readable')
const shared = cloneable(source)
const copy = shared.clone()
const results = await Promise.allSettled([
pipeline(shared, transformA, sinkA),
pipeline(copy, transformB, sinkB),
])
for (const result of results) {
if (result.status === 'rejected') console.error(result.reason)
}A destination failure on one branch needs an explicit policy for the other branch and original source; cloneable-readable does not own that application-level cleanup decision.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| readable-stream | npm | You want the userland Node streams implementation and can build a simple fixed fan-out with PassThrough branches yourself |
| streamx | npm | You are willing to adopt a different stream implementation for tighter control over backpressure and stream internals |
| readable-stream-clone | npm | You need another small stream-cloning package and will evaluate its much smaller API and maintenance record yourself |