async-line-reader
Version:
Read line-by-line from stream, using async.
187 lines (157 loc) • 4.69 kB
text/typescript
import { Readable } from 'stream'
type Resolve<T> = (value?: T | PromiseLike<T>) => void
/**
* Allows async line-wise reading from a stream.
*/
export class AsyncLineReader {
// Params
private stream: Readable
private separator: string
private bufferEncoding: BufferEncoding
// Caches the actual data
private internalBuffer: string
// Indicates if the stream has ended
private ended: boolean
// Caches stream errors
private error: Error
// Callbacks for returned promise.
private resolve: Resolve<String> | null
private reject: Resolve<Error> | null
/**
* Creates a new AsyncLineReader instance.
* @param stream The stream to read from.
* @param separator The line separator.
* @param bufferEncoding The encoding of the buffer, if the stream is a binary stream.
*/
public constructor(stream: Readable, separator: string = '\n', bufferEncoding: BufferEncoding = 'utf-8') {
this.stream = stream
this.separator = separator
this.bufferEncoding = bufferEncoding
this.ended = false
this.internalBuffer = ''
this.resolve = null;
this.reject = null;
this.stream.on('data', (chunk) => this.onData(chunk))
this.stream.on('end', () => this.onEnd())
this.stream.on('error', (err) => this.onError(err))
}
/**
* Checks the type of a chunk and converts it to a string.
*/
private chunkToString(chunk: any) {
if(typeof chunk === 'string') {
return chunk
}
if(chunk instanceof Buffer) {
return chunk.toString(this.bufferEncoding)
}
throw new Error(`Invalid buffer type: ${typeof chunk}`)
}
/**
* Called on stream error.
*/
private onError(err: Error) {
this.consumeError(err)
}
/**
* Called on stream end.
*/
private onEnd() {
this.ended = true
this.tryNotifyWaiting()
}
/**
* Called in case there is new data in the stream.
*/
private onData(chunk: Buffer) {
const data = this.chunkToString(chunk)
// Store the new data. This could be done faster.
this.internalBuffer = this.internalBuffer + data
this.tryNotifyWaiting()
}
/**
* Notifies a waiting promise chain, if any.
*/
private tryNotifyWaiting() {
if(this.resolve) {
const line = this.readNextLineFromBuffer();
// If the stream has ended, we might send a null.
if(line !== null || this.ended) {
this.resolve(line)
this.reject = null
this.resolve = null
}
}
}
/**
* Either rejects a waiting promise with the given error,
* or stores it for later throwing.
*/
private consumeError(error: Error) {
if(this.resolve) {
this.reject(error)
this.reject = null
this.resolve = null
} else {
this.error = error
}
}
/**
* Gets the next available line from the buffer. The line is removed from the buffer.
*
* Returns null when there is no data, regardless of the stream has ended or not.
*/
private readNextLineFromBuffer() {
const idx = this.internalBuffer.indexOf(this.separator)
if(idx >= 0) {
// There is at least one complete line in our buffer.
const line = this.internalBuffer.substring(0, idx)
// Update buffer, remove \n
this.internalBuffer = this.internalBuffer.substring(idx + this.separator.length, this.internalBuffer.length)
return line
} else if(this.ended) {
if(this.internalBuffer === '') {
return null
}
// There is no complete line and the stream has ended.
const line = this.internalBuffer
this.internalBuffer = ''
return line
} else {
// There is no complete line yet.
return null
}
}
/**
* Gets the next line from the stream.
* Returns either a line, a promise that resolves
* when the next line becomes available, or null,
* if the stream has ended and was read completely.
* This method throws if the underlying stream errors.
*/
public async readLine(): Promise<string> {
if(this.error) {
const err = this.error
this.error = null
throw err
}
const line = this.readNextLineFromBuffer();
if(line !== null) {
// There is data available, return it.
return line
} else if(this.ended) {
// Stream is over, return null.
return null
} else {
// No data, but stream ongoing. Async read operation.
return new Promise((resolve, reject) => {
if(this.resolve !== null) {
reject('Another promise chain is already waiting for the next line.')
} else {
this.resolve = resolve
this.reject = reject
}
})
}
}
}