2023-11-01 14:16:58 +01:00
|
|
|
import * as plugins from './smartstream.plugins.js';
|
|
|
|
|
import { Duplex, type DuplexOptions } from 'stream';
|
2023-11-03 21:32:24 +01:00
|
|
|
|
|
|
|
|
export interface IStreamTools {
|
|
|
|
|
truncate: () => void;
|
2024-03-16 18:29:44 +01:00
|
|
|
push: (pipeObject: any) => Promise<boolean>;
|
2023-11-03 13:55:56 +01:00
|
|
|
}
|
2023-11-01 14:16:58 +01:00
|
|
|
|
2023-11-07 21:46:46 +01:00
|
|
|
export interface IStreamWriteFunction<T, rT> {
|
2023-11-03 21:32:24 +01:00
|
|
|
(chunkArg: T, toolsArg: IStreamTools): Promise<rT>;
|
|
|
|
|
}
|
2023-11-01 14:16:58 +01:00
|
|
|
|
2023-11-07 21:46:46 +01:00
|
|
|
export interface IStreamFinalFunction<rT> {
|
2023-11-03 21:32:24 +01:00
|
|
|
(toolsArg: IStreamTools): Promise<rT>;
|
|
|
|
|
}
|
2023-11-01 14:16:58 +01:00
|
|
|
|
2023-11-11 20:30:42 +01:00
|
|
|
export interface ISmartDuplexOptions<TInput, TOutput> extends DuplexOptions {
|
2024-02-25 20:14:19 +01:00
|
|
|
/**
|
|
|
|
|
* wether to print debug logs
|
|
|
|
|
*/
|
2023-11-13 17:43:15 +01:00
|
|
|
debug?: boolean;
|
2024-02-25 20:14:19 +01:00
|
|
|
/**
|
|
|
|
|
* the name of the stream
|
|
|
|
|
*/
|
2023-11-13 17:43:15 +01:00
|
|
|
name?: string;
|
2024-02-25 20:14:19 +01:00
|
|
|
/**
|
|
|
|
|
* a function that is being called to read more stuff from whereever to be processed by the stream
|
|
|
|
|
* @returns
|
|
|
|
|
*/
|
2023-11-03 21:32:24 +01:00
|
|
|
readFunction?: () => Promise<void>;
|
2024-02-25 20:14:19 +01:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* the write function is called for every chunk that is being written to the stream
|
|
|
|
|
* it can push or return chunks (but does not have to) to be written to the readable side of the stream
|
|
|
|
|
*/
|
2023-11-07 21:46:46 +01:00
|
|
|
writeFunction?: IStreamWriteFunction<TInput, TOutput>;
|
2024-10-13 00:02:01 +02:00
|
|
|
|
2024-02-25 20:14:19 +01:00
|
|
|
/**
|
2024-10-13 00:02:01 +02:00
|
|
|
* a final function that is run at the end of the stream
|
2024-02-25 20:14:19 +01:00
|
|
|
*/
|
2023-11-07 21:46:46 +01:00
|
|
|
finalFunction?: IStreamFinalFunction<TOutput>;
|
2023-11-03 21:32:24 +01:00
|
|
|
}
|
2023-11-01 14:16:58 +01:00
|
|
|
|
2023-11-03 21:32:24 +01:00
|
|
|
export class SmartDuplex<TInput = any, TOutput = any> extends Duplex {
|
|
|
|
|
// STATIC
|
2023-11-11 20:30:42 +01:00
|
|
|
static fromBuffer(buffer: Buffer, options?: ISmartDuplexOptions<any, any>): SmartDuplex {
|
|
|
|
|
const smartDuplex = new SmartDuplex(options);
|
2023-11-01 14:16:58 +01:00
|
|
|
process.nextTick(() => {
|
2023-11-11 20:30:42 +01:00
|
|
|
smartDuplex.push(buffer);
|
|
|
|
|
smartDuplex.push(null); // Signal the end of the data
|
2023-11-01 14:16:58 +01:00
|
|
|
});
|
2023-11-11 20:30:42 +01:00
|
|
|
return smartDuplex;
|
2023-11-01 14:16:58 +01:00
|
|
|
}
|
|
|
|
|
|
2024-10-16 01:02:46 +02:00
|
|
|
public static fromWebReadableStream<T = any>(
|
|
|
|
|
readableStream: ReadableStream<T>
|
|
|
|
|
): SmartDuplex<T, T> {
|
|
|
|
|
const smartDuplex = new SmartDuplex<T, T>({
|
2026-03-02 06:55:11 +00:00
|
|
|
objectMode: true,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Acquire reader ONCE
|
|
|
|
|
const reader = readableStream.getReader();
|
|
|
|
|
let reading = false;
|
|
|
|
|
|
|
|
|
|
// Override _read to pull from the web reader
|
|
|
|
|
smartDuplex._read = function (_size: number) {
|
|
|
|
|
if (reading) return;
|
|
|
|
|
reading = true;
|
|
|
|
|
reader.read().then(
|
|
|
|
|
({ value, done }) => {
|
|
|
|
|
reading = false;
|
|
|
|
|
if (done) {
|
|
|
|
|
smartDuplex.push(null);
|
|
|
|
|
} else {
|
|
|
|
|
smartDuplex.push(value);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(err) => {
|
|
|
|
|
reading = false;
|
|
|
|
|
smartDuplex.destroy(err);
|
2024-10-16 01:02:46 +02:00
|
|
|
}
|
2026-03-02 06:55:11 +00:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Cancel reader on destroy
|
|
|
|
|
smartDuplex.on('close', () => {
|
|
|
|
|
reader.cancel().catch(() => {});
|
2024-10-16 01:02:46 +02:00
|
|
|
});
|
2026-03-02 06:55:11 +00:00
|
|
|
|
2024-10-16 01:02:46 +02:00
|
|
|
return smartDuplex;
|
|
|
|
|
}
|
|
|
|
|
|
2023-11-03 21:32:24 +01:00
|
|
|
// INSTANCE
|
2026-03-02 06:55:11 +00:00
|
|
|
private backpressuredArray: plugins.lik.BackpressuredArray<TOutput>;
|
2023-11-13 17:43:15 +01:00
|
|
|
public options: ISmartDuplexOptions<TInput, TOutput>;
|
2026-03-02 06:55:11 +00:00
|
|
|
private _consumerWantsData = false;
|
|
|
|
|
private _readFunctionRunning = false;
|
|
|
|
|
|
2023-11-13 17:43:15 +01:00
|
|
|
private debugLog(messageArg: string) {
|
|
|
|
|
if (this.options.debug) {
|
|
|
|
|
console.log(messageArg);
|
|
|
|
|
}
|
|
|
|
|
}
|
2023-11-03 21:32:24 +01:00
|
|
|
|
2023-11-11 20:30:42 +01:00
|
|
|
constructor(optionsArg?: ISmartDuplexOptions<TInput, TOutput>) {
|
2026-03-02 06:55:11 +00:00
|
|
|
const safeOptions = optionsArg || {} as ISmartDuplexOptions<TInput, TOutput>;
|
2024-10-13 00:02:01 +02:00
|
|
|
super(
|
|
|
|
|
Object.assign(
|
|
|
|
|
{
|
|
|
|
|
highWaterMark: 1,
|
|
|
|
|
},
|
2026-03-02 06:55:11 +00:00
|
|
|
safeOptions
|
2024-10-13 00:02:01 +02:00
|
|
|
)
|
|
|
|
|
);
|
2026-03-02 06:55:11 +00:00
|
|
|
this.options = safeOptions;
|
2024-10-13 00:02:01 +02:00
|
|
|
this.backpressuredArray = new plugins.lik.BackpressuredArray<TOutput>(
|
|
|
|
|
this.options.highWaterMark || 1
|
|
|
|
|
);
|
2023-11-03 21:32:24 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 06:55:11 +00:00
|
|
|
/**
|
|
|
|
|
* Synchronously drains items from the backpressuredArray into the readable side.
|
|
|
|
|
* Stops when push() returns false (consumer is full) or array is empty.
|
|
|
|
|
*/
|
|
|
|
|
private _drainBackpressuredArray(): void {
|
|
|
|
|
while (this.backpressuredArray.data.length > 0) {
|
|
|
|
|
const nextChunk = this.backpressuredArray.shift();
|
|
|
|
|
if (nextChunk === null) {
|
|
|
|
|
// EOF signal — push null to end readable side
|
|
|
|
|
this.push(null);
|
|
|
|
|
this._consumerWantsData = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const canPushMore = this.push(nextChunk);
|
|
|
|
|
if (!canPushMore) {
|
|
|
|
|
this._consumerWantsData = false;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// _read must NOT be async — Node.js ignores the return value
|
|
|
|
|
public _read(size: number): void {
|
2023-11-13 17:43:15 +01:00
|
|
|
this.debugLog(`${this.options.name}: read was called`);
|
2026-03-02 06:55:11 +00:00
|
|
|
this._consumerWantsData = true;
|
|
|
|
|
|
|
|
|
|
// Drain any buffered items first
|
|
|
|
|
if (this.backpressuredArray.data.length > 0) {
|
|
|
|
|
this._drainBackpressuredArray();
|
2023-11-13 17:43:15 +01:00
|
|
|
}
|
2026-03-02 06:55:11 +00:00
|
|
|
|
|
|
|
|
// If readFunction exists and is not already running, start it
|
|
|
|
|
if (this.options.readFunction && !this._readFunctionRunning) {
|
|
|
|
|
this._readFunctionRunning = true;
|
|
|
|
|
this.options.readFunction().then(
|
|
|
|
|
() => { this._readFunctionRunning = false; },
|
|
|
|
|
(err) => { this._readFunctionRunning = false; this.destroy(err); }
|
|
|
|
|
);
|
2023-11-03 21:32:24 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-13 00:02:01 +02:00
|
|
|
public async backpressuredPush(pushArg: TOutput) {
|
2023-11-14 10:29:44 +01:00
|
|
|
const canPushMore = this.backpressuredArray.push(pushArg);
|
2026-03-02 06:55:11 +00:00
|
|
|
// Try to drain if the consumer wants data
|
|
|
|
|
if (this._consumerWantsData) {
|
|
|
|
|
this._drainBackpressuredArray();
|
|
|
|
|
}
|
2023-11-14 10:29:44 +01:00
|
|
|
if (!canPushMore) {
|
|
|
|
|
this.debugLog(`${this.options.name}: cannot push more`);
|
|
|
|
|
await this.backpressuredArray.waitForSpace();
|
|
|
|
|
this.debugLog(`${this.options.name}: can push more again`);
|
|
|
|
|
}
|
2024-03-16 18:29:44 +01:00
|
|
|
return canPushMore;
|
2024-10-13 00:02:01 +02:00
|
|
|
}
|
2023-11-14 10:29:44 +01:00
|
|
|
|
2023-11-12 22:34:55 +01:00
|
|
|
private asyncWritePromiseObjectmap = new plugins.lik.ObjectMap<Promise<any>>();
|
2026-03-02 06:55:11 +00:00
|
|
|
|
|
|
|
|
// _write must NOT be async — Node.js ignores the return value
|
|
|
|
|
public _write(chunk: TInput, encoding: string, callback: (error?: Error | null) => void) {
|
2023-11-13 17:43:15 +01:00
|
|
|
if (!this.options.writeFunction) {
|
2023-11-03 21:32:24 +01:00
|
|
|
return callback(new Error('No stream function provided'));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 06:55:11 +00:00
|
|
|
let callbackCalled = false;
|
|
|
|
|
const safeCallback = (err?: Error | null) => {
|
|
|
|
|
if (!callbackCalled) {
|
|
|
|
|
callbackCalled = true;
|
|
|
|
|
callback(err);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2023-11-13 17:43:15 +01:00
|
|
|
let isTruncated = false;
|
2023-11-03 21:32:24 +01:00
|
|
|
const tools: IStreamTools = {
|
|
|
|
|
truncate: () => {
|
2023-11-13 17:43:15 +01:00
|
|
|
isTruncated = true;
|
2026-03-02 06:55:11 +00:00
|
|
|
safeCallback();
|
|
|
|
|
this.push(null);
|
2023-11-03 21:32:24 +01:00
|
|
|
},
|
2023-11-13 18:19:11 +01:00
|
|
|
push: async (pushArg: TOutput) => {
|
2024-03-16 18:29:44 +01:00
|
|
|
return await this.backpressuredPush(pushArg);
|
2024-10-13 00:02:01 +02:00
|
|
|
},
|
2023-11-03 21:32:24 +01:00
|
|
|
};
|
|
|
|
|
|
2026-03-02 06:55:11 +00:00
|
|
|
const writeDeferred = plugins.smartpromise.defer();
|
|
|
|
|
this.asyncWritePromiseObjectmap.add(writeDeferred.promise);
|
|
|
|
|
|
|
|
|
|
this.options.writeFunction(chunk, tools).then(
|
|
|
|
|
(modifiedChunk) => {
|
|
|
|
|
if (isTruncated) {
|
|
|
|
|
writeDeferred.resolve();
|
|
|
|
|
this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const finish = () => {
|
|
|
|
|
safeCallback();
|
|
|
|
|
writeDeferred.resolve();
|
|
|
|
|
this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
|
|
|
|
|
};
|
|
|
|
|
if (modifiedChunk !== undefined && modifiedChunk !== null) {
|
|
|
|
|
this.backpressuredPush(modifiedChunk).then(finish, (err) => {
|
|
|
|
|
safeCallback(err);
|
|
|
|
|
writeDeferred.resolve();
|
|
|
|
|
this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
finish();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(err) => {
|
|
|
|
|
safeCallback(err);
|
|
|
|
|
writeDeferred.resolve();
|
2023-11-12 22:34:55 +01:00
|
|
|
this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
|
2026-03-02 06:55:11 +00:00
|
|
|
}
|
|
|
|
|
);
|
2023-11-03 21:32:24 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 06:55:11 +00:00
|
|
|
// _final must NOT be async — Node.js ignores the return value
|
|
|
|
|
public _final(callback: (error?: Error | null) => void) {
|
|
|
|
|
let callbackCalled = false;
|
|
|
|
|
const safeCallback = (err?: Error | null) => {
|
|
|
|
|
if (!callbackCalled) {
|
|
|
|
|
callbackCalled = true;
|
|
|
|
|
callback(err);
|
|
|
|
|
}
|
|
|
|
|
};
|
2023-11-03 21:32:24 +01:00
|
|
|
|
2026-03-02 06:55:11 +00:00
|
|
|
Promise.all(this.asyncWritePromiseObjectmap.getArray()).then(() => {
|
|
|
|
|
if (this.options.finalFunction) {
|
|
|
|
|
const tools: IStreamTools = {
|
|
|
|
|
truncate: () => safeCallback(),
|
|
|
|
|
push: async (pipeObject) => {
|
|
|
|
|
return await this.backpressuredPush(pipeObject);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
this.options.finalFunction(tools).then(
|
|
|
|
|
(finalChunk) => {
|
|
|
|
|
const pushNull = () => {
|
|
|
|
|
this.backpressuredArray.push(null);
|
|
|
|
|
if (this._consumerWantsData) {
|
|
|
|
|
this._drainBackpressuredArray();
|
|
|
|
|
}
|
|
|
|
|
safeCallback();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (finalChunk !== undefined && finalChunk !== null) {
|
|
|
|
|
this.backpressuredPush(finalChunk).then(pushNull, (err) => {
|
|
|
|
|
safeCallback(err);
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
pushNull();
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
(err) => {
|
|
|
|
|
this.backpressuredArray.push(null);
|
|
|
|
|
if (this._consumerWantsData) {
|
|
|
|
|
this._drainBackpressuredArray();
|
|
|
|
|
}
|
|
|
|
|
safeCallback(err);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} else {
|
2023-11-13 19:06:02 +01:00
|
|
|
this.backpressuredArray.push(null);
|
2026-03-02 06:55:11 +00:00
|
|
|
if (this._consumerWantsData) {
|
|
|
|
|
this._drainBackpressuredArray();
|
|
|
|
|
}
|
|
|
|
|
safeCallback();
|
2023-11-03 21:32:24 +01:00
|
|
|
}
|
2026-03-02 06:55:11 +00:00
|
|
|
}, (err) => {
|
|
|
|
|
safeCallback(err);
|
|
|
|
|
});
|
2023-11-03 21:32:24 +01:00
|
|
|
}
|
2024-06-02 23:40:52 +02:00
|
|
|
|
2024-10-13 00:02:01 +02:00
|
|
|
public async getWebStreams(): Promise<{ readable: ReadableStream; writable: WritableStream }> {
|
2024-06-02 23:40:52 +02:00
|
|
|
const duplex = this;
|
2026-03-02 06:55:11 +00:00
|
|
|
let readableClosed = false;
|
|
|
|
|
|
2024-06-02 23:40:52 +02:00
|
|
|
const readable = new ReadableStream({
|
|
|
|
|
start(controller) {
|
2026-03-02 06:55:11 +00:00
|
|
|
const onReadable = () => {
|
2024-06-02 23:40:52 +02:00
|
|
|
let chunk;
|
|
|
|
|
while (null !== (chunk = duplex.read())) {
|
|
|
|
|
controller.enqueue(chunk);
|
|
|
|
|
}
|
2026-03-02 06:55:11 +00:00
|
|
|
};
|
2024-10-13 00:02:01 +02:00
|
|
|
|
2026-03-02 06:55:11 +00:00
|
|
|
const onEnd = () => {
|
|
|
|
|
if (!readableClosed) {
|
|
|
|
|
readableClosed = true;
|
|
|
|
|
controller.close();
|
|
|
|
|
}
|
|
|
|
|
cleanup();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cleanup = () => {
|
|
|
|
|
duplex.removeListener('readable', onReadable);
|
|
|
|
|
duplex.removeListener('end', onEnd);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
duplex.on('readable', onReadable);
|
|
|
|
|
duplex.on('end', onEnd);
|
2024-06-02 23:40:52 +02:00
|
|
|
},
|
|
|
|
|
cancel(reason) {
|
|
|
|
|
duplex.destroy(new Error(reason));
|
2024-10-13 00:02:01 +02:00
|
|
|
},
|
2024-06-02 23:40:52 +02:00
|
|
|
});
|
2024-10-13 00:02:01 +02:00
|
|
|
|
2024-06-02 23:40:52 +02:00
|
|
|
const writable = new WritableStream({
|
|
|
|
|
write(chunk) {
|
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
2026-03-02 06:55:11 +00:00
|
|
|
let resolved = false;
|
|
|
|
|
const onDrain = () => {
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2024-06-02 23:40:52 +02:00
|
|
|
const isBackpressured = !duplex.write(chunk, (error) => {
|
|
|
|
|
if (error) {
|
2026-03-02 06:55:11 +00:00
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
duplex.removeListener('drain', onDrain);
|
|
|
|
|
reject(error);
|
|
|
|
|
}
|
|
|
|
|
} else if (!isBackpressured && !resolved) {
|
|
|
|
|
resolved = true;
|
2024-06-02 23:40:52 +02:00
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
});
|
2024-10-13 00:02:01 +02:00
|
|
|
|
2024-06-02 23:40:52 +02:00
|
|
|
if (isBackpressured) {
|
2026-03-02 06:55:11 +00:00
|
|
|
duplex.once('drain', onDrain);
|
2024-06-02 23:40:52 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
close() {
|
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
2026-03-02 06:55:11 +00:00
|
|
|
duplex.end((err: Error | null) => {
|
|
|
|
|
if (err) reject(err);
|
|
|
|
|
else resolve();
|
|
|
|
|
});
|
2024-06-02 23:40:52 +02:00
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
abort(reason) {
|
|
|
|
|
duplex.destroy(new Error(reason));
|
2024-10-13 00:02:01 +02:00
|
|
|
},
|
2024-06-02 23:40:52 +02:00
|
|
|
});
|
2024-10-13 00:02:01 +02:00
|
|
|
|
2024-06-02 23:40:52 +02:00
|
|
|
return { readable, writable };
|
|
|
|
|
}
|
2023-11-01 14:16:58 +01:00
|
|
|
}
|