feat(smartduplex): improve backpressure handling and web/node stream interoperability

This commit is contained in:
2026-03-02 06:55:11 +00:00
parent 1262c48fe9
commit 2acf1972a2
23 changed files with 1416 additions and 511 deletions

379
test/test.smartduplex.ts Normal file
View File

@@ -0,0 +1,379 @@
import { expect, tap } from '@push.rocks/tapbundle';
import * as fs from 'fs';
import * as smartstream from '../ts/index.js';
import { SmartDuplex } from '../ts/smartstream.classes.smartduplex.js';
// =============================================
// Constructor
// =============================================
tap.test('SmartDuplex: should construct with no options', async () => {
const duplex = new SmartDuplex();
expect(duplex).toBeInstanceOf(SmartDuplex);
});
tap.test('SmartDuplex: should construct with options', async () => {
const duplex = new SmartDuplex({
objectMode: true,
writeFunction: async (chunk) => chunk,
});
expect(duplex).toBeInstanceOf(SmartDuplex);
});
// =============================================
// fromBuffer
// =============================================
tap.test('SmartDuplex: should create from a Buffer', async () => {
const bufferData = Buffer.from('This is a test buffer');
const stream = SmartDuplex.fromBuffer(bufferData, {});
let receivedData = Buffer.alloc(0);
return new Promise<void>((resolve) => {
stream.on('data', (chunk: Buffer) => {
receivedData = Buffer.concat([receivedData, chunk]);
});
stream.on('end', () => {
expect(receivedData.toString()).toEqual(bufferData.toString());
resolve();
});
});
});
// =============================================
// writeFunction
// =============================================
tap.test('SmartDuplex: should transform chunks via writeFunction', async (tools) => {
const results: string[] = [];
const transform = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async (chunk) => {
return chunk.toUpperCase();
},
});
const done = tools.defer();
transform.on('data', (chunk: string) => {
results.push(chunk);
});
transform.on('end', () => {
expect(results).toContain('HELLO');
expect(results).toContain('WORLD');
done.resolve();
});
transform.write('hello');
transform.write('world');
transform.end();
await done.promise;
});
tap.test('SmartDuplex: writeFunction returning undefined should not push', async (tools) => {
const results: any[] = [];
const transform = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async () => {
return undefined;
},
});
const done = tools.defer();
transform.on('data', (chunk: any) => {
results.push(chunk);
});
transform.on('end', () => {
expect(results.length).toEqual(0);
done.resolve();
});
transform.write('hello');
transform.end();
await done.promise;
});
// =============================================
// tools.push — multiple outputs
// =============================================
tap.test('SmartDuplex: should emit multiple chunks via tools.push', async (tools) => {
const results: string[] = [];
const splitter = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async (chunk, streamTools) => {
const words = chunk.split(' ');
for (const word of words) {
await streamTools.push(word);
}
},
});
const done = tools.defer();
splitter.on('data', (chunk: string) => results.push(chunk));
splitter.on('end', () => {
expect(results).toContain('hello');
expect(results).toContain('beautiful');
expect(results).toContain('world');
done.resolve();
});
splitter.write('hello beautiful world');
splitter.end();
await done.promise;
});
// =============================================
// finalFunction
// =============================================
tap.test('SmartDuplex: should emit final chunk via finalFunction', async (tools) => {
const results: string[] = [];
let count = 0;
const aggregator = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async () => {
count++;
return undefined;
},
finalFunction: async () => {
return `total: ${count}`;
},
});
const done = tools.defer();
aggregator.on('data', (chunk: string) => results.push(chunk));
aggregator.on('end', () => {
expect(results.length).toEqual(1);
expect(results[0]).toEqual('total: 2');
done.resolve();
});
aggregator.write('a');
aggregator.write('b');
aggregator.end();
await done.promise;
});
tap.test('SmartDuplex: finalFunction can push multiple chunks via tools.push', async (tools) => {
const results: string[] = [];
const stream = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async (chunk) => chunk,
finalFunction: async (streamTools) => {
await streamTools.push('final1');
await streamTools.push('final2');
},
});
const done = tools.defer();
stream.on('data', (chunk: string) => results.push(chunk));
stream.on('end', () => {
expect(results).toContain('hello');
expect(results).toContain('final1');
expect(results).toContain('final2');
done.resolve();
});
stream.write('hello');
stream.end();
await done.promise;
});
// =============================================
// truncate
// =============================================
tap.test('SmartDuplex: should truncate stream early', async (tools) => {
const results: string[] = [];
const limiter = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async (chunk, streamTools) => {
if (chunk === 'STOP') {
streamTools.truncate();
return undefined;
}
return chunk;
},
});
const done = tools.defer();
limiter.on('data', (chunk: string) => results.push(chunk));
limiter.on('end', () => {
expect(results).toContain('a');
expect(results).toContain('b');
expect(results).not.toContain('STOP');
done.resolve();
});
limiter.write('a');
limiter.write('b');
// Write STOP on next tick to allow previous writes to flush
process.nextTick(() => {
limiter.write('STOP');
});
await done.promise;
});
// =============================================
// Error handling
// =============================================
tap.test('SmartDuplex: should emit error when writeFunction throws', async (tools) => {
const stream = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async () => {
throw new Error('write error');
},
});
const done = tools.defer();
stream.on('error', (err) => {
expect(err.message).toEqual('write error');
done.resolve();
});
stream.write('test');
await done.promise;
});
tap.test('SmartDuplex: should error when no writeFunction and data is written', async (tools) => {
const stream = new SmartDuplex<string, string>({
objectMode: true,
});
const done = tools.defer();
stream.on('error', (err) => {
expect(err.message).toEqual('No stream function provided');
done.resolve();
});
stream.write('test');
await done.promise;
});
// =============================================
// fromWebReadableStream
// =============================================
tap.test('SmartDuplex: should create from a Web ReadableStream', async (tools) => {
const chunks = ['hello', 'world', 'foo'];
const webReadable = new ReadableStream<string>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(chunk);
}
controller.close();
}
});
const duplex = SmartDuplex.fromWebReadableStream(webReadable);
const results: string[] = [];
const done = tools.defer();
duplex.on('data', (chunk: string) => results.push(chunk));
duplex.on('end', () => {
expect(results).toEqual(chunks);
done.resolve();
});
await done.promise;
});
// =============================================
// getWebStreams
// =============================================
tap.test('SmartDuplex: should provide web streams via getWebStreams()', async () => {
const duplex = new SmartDuplex<string, string>({
objectMode: true,
writeFunction: async (chunk) => {
return chunk.toUpperCase();
},
});
const { readable, writable } = await duplex.getWebStreams();
const writer = writable.getWriter();
const reader = readable.getReader();
await writer.write('hello');
await writer.write('world');
await writer.close();
const results: string[] = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
results.push(value);
}
expect(results).toContain('HELLO');
expect(results).toContain('WORLD');
});
// =============================================
// Debug mode
// =============================================
tap.test('SmartDuplex: debug mode should not crash', async (tools) => {
const stream = new SmartDuplex<string, string>({
name: 'DebugStream',
debug: true,
objectMode: true,
writeFunction: async (chunk) => chunk,
});
const done = tools.defer();
stream.on('data', () => {});
stream.on('end', () => done.resolve());
stream.write('test');
stream.end();
await done.promise;
});
// =============================================
// Pipe with file read
// =============================================
tap.test('SmartDuplex: should handle a read stream pipeline', async () => {
const streamWrapper = new smartstream.StreamWrapper([
fs.createReadStream('./test/assets/readabletext.txt'),
new smartstream.SmartDuplex({
writeFunction: async (chunkStringArg: Buffer, streamTools) => {
const result = chunkStringArg.toString().substr(0, 100);
streamTools.push('wow =========== \n');
return Buffer.from(result);
},
finalFunction: async () => {
return Buffer.from('this is the end');
},
}),
new smartstream.SmartDuplex({
writeFunction: async (chunkStringArg) => {
// consume data
},
finalFunction: async (streamTools) => {
streamTools.push(null);
},
})
]);
await streamWrapper.run();
});
export default tap.start();