stream-to-it
Version:
Convert Node.js streams to streaming iterables
131 lines (110 loc) • 3.57 kB
text/typescript
import type { Sink, Source } from 'it-stream-types'
import type { Writable } from 'node:stream'
/**
* Convert a Node.js [`Writable`](https://nodejs.org/dist/latest/docs/api/stream.html#class-streamwritable)
* stream to a [sink](https://achingbrain.github.io/it-stream-types/interfaces/Sink.html).
*/
export function sink <T> (writable: Writable): Sink<Source<T>, Promise<void>> {
return async (source: Source<T>): Promise<void> => {
const maybeEndSource = async (): Promise<void> => {
if (isAsyncGenerator(source)) {
await source.return(undefined)
}
}
let error: Error | undefined
let errCb: ((err: Error) => void) | undefined
const errorHandler = (err: Error): void => {
error = err
// When the writable errors, try to end the source to exit iteration early
maybeEndSource()
.catch(err => {
err = new AggregateError([
error,
err
], 'The Writable emitted an error, additionally an error occurred while ending the Source')
})
.finally(() => {
errCb?.(err)
})
}
let closeCb: (() => void) | undefined
let closed = false
const closeHandler = (): void => {
closed = true
closeCb?.()
}
let finishCb: (() => void) | undefined
let finished = false
const finishHandler = (): void => {
finished = true
finishCb?.()
}
let drainCb: (() => void) | undefined
const drainHandler = (): void => {
drainCb?.()
}
const waitForDrainOrClose = async (): Promise<void> => {
return new Promise<void>((resolve, reject) => {
closeCb = drainCb = resolve
errCb = reject
writable.once('drain', drainHandler)
})
}
const waitForDone = async (): Promise<void> => {
// Immediately try to end the source
await maybeEndSource()
return new Promise<void>((resolve, reject) => {
if (closed || finished || (error != null)) {
resolve()
return
}
finishCb = closeCb = resolve
errCb = reject
})
}
const cleanup = (): void => {
writable.removeListener('error', errorHandler)
writable.removeListener('close', closeHandler)
writable.removeListener('finish', finishHandler)
writable.removeListener('drain', drainHandler)
}
writable.once('error', errorHandler)
writable.once('close', closeHandler)
writable.once('finish', finishHandler)
try {
for await (const value of source) {
if (!writable.writable || writable.destroyed || (error != null)) {
break
}
if (!writable.write(value as any)) {
await waitForDrainOrClose()
}
}
} catch (err: any) {
// error is set by stream error handler so only destroy stream if source
// threw
if (error == null) {
writable.destroy(err)
}
// could we be obscuring an error here?
error = err
}
try {
// We're done writing, end everything (n.b. stream may be destroyed at this
// point but then this is a no-op)
if (writable.writable) {
writable.end()
}
// Wait until we close or finish. This supports halfClosed streams
await waitForDone()
// Notify the user an error occurred
if (error != null) throw error
} finally {
// Clean up listeners
cleanup()
}
}
}
function isAsyncGenerator <T = any> (obj?: any): obj is AsyncGenerator<T> {
return obj.return != null
}