initial import
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
tags: ['v*']
|
||||||
|
pull_request:
|
||||||
|
branches: [master]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
node: [18, 20, 22]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
- name: Auth for Gitea npm registry
|
||||||
|
run: |
|
||||||
|
echo "@${{ github.repository_owner }}:registry=https://hub.kl1.tenere.ai/api/packages/${{ github.repository_owner }}/npm/" > .npmrc
|
||||||
|
echo "//hub.kl1.tenere.ai/api/packages/${{ github.repository_owner }}/npm/:_authToken=${{ secrets.PACKAGE_TOKEN }}" >> .npmrc
|
||||||
|
- run: npm install
|
||||||
|
- run: npm test
|
||||||
|
- run: npm run bench
|
||||||
|
if: matrix.node == 22
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@push-stream-std:registry=https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/
|
||||||
|
//hub.kl1.tenere.ai/api/packages/push-stream-std/npm/:_authToken=${PACKAGE_TOKEN}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
Tenere Labs Inc. — All Rights Reserved.
|
||||||
|
|
||||||
|
PROPRIETARY AND CONFIDENTIAL.
|
||||||
|
Source-available for auditing and inspection only.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# stream-to-push-stream
|
||||||
|
|
||||||
|
Convert native Node.js streams to push-stream source, sink, and duplex interfaces.
|
||||||
|
|
||||||
|
Published as `@push-stream-std/stream-to-push-stream`.
|
||||||
|
|
||||||
|
## Synopsis
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { source, sink, duplex } from '@push-stream-std/stream-to-push-stream';
|
||||||
|
import { Readable, Writable, Duplex } from 'node:stream';
|
||||||
|
import { PassThrough } from 'node:stream';
|
||||||
|
|
||||||
|
// Convert a Readable to a push-stream source
|
||||||
|
const readable = Readable.from(['a', 'b', 'c']);
|
||||||
|
const pushSource = source(readable);
|
||||||
|
|
||||||
|
const pushSink = {
|
||||||
|
paused: false,
|
||||||
|
write(v) { console.log(v); },
|
||||||
|
end() { console.log('done'); }
|
||||||
|
};
|
||||||
|
|
||||||
|
pushSource.pipe(pushSink);
|
||||||
|
// Output: a, b, c, done
|
||||||
|
|
||||||
|
// Convert a Writable to a push-stream sink
|
||||||
|
const writable = new PassThrough();
|
||||||
|
const pushSink2 = sink(writable);
|
||||||
|
|
||||||
|
// Convert a Duplex to a push-stream source
|
||||||
|
const duplexStream = new PassThrough();
|
||||||
|
const pushSource2 = duplex(duplexStream);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm config set @push-stream-std:registry https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/
|
||||||
|
npm config set -- //hub.kl1.tenere.ai/api/packages/push-stream-std/npm/:_authToken "$PACKAGE_TOKEN"
|
||||||
|
npm install @push-stream-std/stream-to-push-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Utilities for bridging between the Node.js `stream` module (Readable, Writable, Duplex) and the push-stream protocol. PushSource wraps a Readable, listening to `data`/`end`/`error` events and implementing `pipe()`, `resume()`, and `abort()`. PushSink wraps a Writable, translating `write()` calls to `writable.write()` with `drain`-based backpressure and `end()` to `writable.end()`. PushDuplex wraps a Duplex stream as a combined source/sink for bidirectional push-stream pipelines.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `source(readableStream)`
|
||||||
|
|
||||||
|
Converts a Node.js `Readable` to a push-stream source.
|
||||||
|
|
||||||
|
- **readableStream** `Readable` – A Node.js readable stream.
|
||||||
|
- **Returns** A push-stream source with `pipe()`, `resume()`, `abort()`, `paused`, and `ended`.
|
||||||
|
|
||||||
|
### `sink(writableStream)`
|
||||||
|
|
||||||
|
Converts a Node.js `Writable` to a push-stream sink.
|
||||||
|
|
||||||
|
- **writableStream** `Writable` – A Node.js writable stream.
|
||||||
|
- **Returns** A push-stream sink with `pipe()`, `write()`, `end()`, `abort()`, `paused`, and `ended`.
|
||||||
|
|
||||||
|
### `duplex(duplexStream)`
|
||||||
|
|
||||||
|
Wraps a Node.js `Duplex` stream as a push-stream source.
|
||||||
|
|
||||||
|
- **duplexStream** `Duplex` – A Node.js duplex stream.
|
||||||
|
- **Returns** A push-stream source with `pipe()`, `resume()`, `abort()`, `paused`, and `ended`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests cover readable-to-source data flow and end handling, writable-to-sink write and backpressure, duplex stream wrapping, error propagation, and abort cleanup.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Node.js 18 or later. ESM only.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
import { Readable, Writable, Duplex } from 'stream';
|
||||||
|
import { source, sink, duplex } from '../index.js';
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 16384;
|
||||||
|
const CHUNK_COUNT = 100000;
|
||||||
|
|
||||||
|
function createBenchmarkReadable() {
|
||||||
|
const data = Buffer.alloc(CHUNK_SIZE, 'x');
|
||||||
|
let count = 0;
|
||||||
|
return new Readable({
|
||||||
|
highWaterMark: 1024 * 1024,
|
||||||
|
read() {
|
||||||
|
if (count < CHUNK_COUNT) {
|
||||||
|
count++;
|
||||||
|
this.push(data);
|
||||||
|
} else {
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBenchmarkWritable() {
|
||||||
|
let bytesWritten = 0;
|
||||||
|
const w = new Writable({
|
||||||
|
highWaterMark: 1024 * 1024,
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
bytesWritten += chunk.length;
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return w;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function benchmarkSource() {
|
||||||
|
const readable = createBenchmarkReadable();
|
||||||
|
const pushSrc = source(readable);
|
||||||
|
const received = [];
|
||||||
|
let byteCount = 0;
|
||||||
|
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
pushSrc.pipe({
|
||||||
|
write(data) {
|
||||||
|
byteCount += data.length;
|
||||||
|
},
|
||||||
|
end(err) {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const duration = Number(end - start) / 1e9;
|
||||||
|
const throughput = (byteCount / duration / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
|
return { duration, throughput, bytes: byteCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function benchmarkSink() {
|
||||||
|
const writable = createBenchmarkWritable();
|
||||||
|
const pushSnk = sink(writable);
|
||||||
|
const data = Buffer.alloc(CHUNK_SIZE, 'x');
|
||||||
|
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
|
||||||
|
for (let i = 0; i < CHUNK_COUNT; i++) {
|
||||||
|
pushSnk.write(data);
|
||||||
|
}
|
||||||
|
pushSnk.end();
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
writable.on('finish', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const duration = Number(end - start) / 1e9;
|
||||||
|
const bytes = CHUNK_COUNT * CHUNK_SIZE;
|
||||||
|
const throughput = (bytes / duration / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
|
return { duration, throughput, bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function benchmarkDuplex() {
|
||||||
|
const data = Buffer.alloc(CHUNK_SIZE, 'x');
|
||||||
|
let count = 0;
|
||||||
|
let byteCount = 0;
|
||||||
|
|
||||||
|
const duplexStream = new Duplex({
|
||||||
|
highWaterMark: 1024 * 1024,
|
||||||
|
read() {
|
||||||
|
if (count < CHUNK_COUNT) {
|
||||||
|
count++;
|
||||||
|
this.push(data);
|
||||||
|
} else {
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushDup = duplex(duplexStream);
|
||||||
|
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
pushDup.pipe({
|
||||||
|
write(data) {
|
||||||
|
byteCount += data.length;
|
||||||
|
},
|
||||||
|
end(err) {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const duration = Number(end - start) / 1e9;
|
||||||
|
const throughput = (byteCount / duration / 1024 / 1024).toFixed(2);
|
||||||
|
|
||||||
|
return { duration, throughput, bytes: byteCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function benchmarkObjectMode() {
|
||||||
|
const arr = Array.from({ length: CHUNK_COUNT }, (_, i) => ({ id: i, value: Math.random() }));
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
const readable = new Readable({
|
||||||
|
objectMode: true,
|
||||||
|
highWaterMark: 1024,
|
||||||
|
read() {
|
||||||
|
if (index < arr.length) {
|
||||||
|
this.push(arr[index++]);
|
||||||
|
} else {
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushSrc = source(readable);
|
||||||
|
let itemCount = 0;
|
||||||
|
|
||||||
|
const start = process.hrtime.bigint();
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
pushSrc.pipe({
|
||||||
|
write(data) {
|
||||||
|
itemCount++;
|
||||||
|
},
|
||||||
|
end(err) {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = process.hrtime.bigint();
|
||||||
|
const duration = Number(end - start) / 1e9;
|
||||||
|
const throughput = (itemCount / duration).toFixed(0);
|
||||||
|
|
||||||
|
return { duration, throughput, items: itemCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBenchmarks() {
|
||||||
|
console.log('stream-to-push-stream benchmarks');
|
||||||
|
console.log('================================');
|
||||||
|
console.log(`Chunk size: ${CHUNK_SIZE} bytes`);
|
||||||
|
console.log(`Chunk count: ${CHUNK_COUNT}`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
console.log('Running benchmarks...\n');
|
||||||
|
|
||||||
|
console.log('1. Source (Readable -> Push-Source):');
|
||||||
|
const sourceResult = await benchmarkSource();
|
||||||
|
console.log(` Duration: ${sourceResult.duration.toFixed(3)}s`);
|
||||||
|
console.log(` Throughput: ${sourceResult.throughput} MB/s`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
console.log('2. Sink (Writable -> Push-Sink):');
|
||||||
|
const sinkResult = await benchmarkSink();
|
||||||
|
console.log(` Duration: ${sinkResult.duration.toFixed(3)}s`);
|
||||||
|
console.log(` Throughput: ${sinkResult.throughput} MB/s`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
console.log('3. Duplex (Duplex -> Push-Duplex):');
|
||||||
|
const duplexResult = await benchmarkDuplex();
|
||||||
|
console.log(` Duration: ${duplexResult.duration.toFixed(3)}s`);
|
||||||
|
console.log(` Throughput: ${duplexResult.throughput} MB/s`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
console.log('4. Object Mode (Readable Object -> Push-Source):');
|
||||||
|
const objectResult = await benchmarkObjectMode();
|
||||||
|
console.log(` Duration: ${objectResult.duration.toFixed(3)}s`);
|
||||||
|
console.log(` Throughput: ${objectResult.throughput} items/s`);
|
||||||
|
console.log();
|
||||||
|
|
||||||
|
console.log('Benchmark complete.');
|
||||||
|
}
|
||||||
|
|
||||||
|
runBenchmarks().catch(console.error);
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Readable, Writable, Duplex } from 'stream';
|
||||||
|
|
||||||
|
class PushSource {
|
||||||
|
constructor(readable) {
|
||||||
|
this.readable = readable;
|
||||||
|
this.sink = null;
|
||||||
|
this.paused = true;
|
||||||
|
this.ended = false;
|
||||||
|
this._endedWithError = null;
|
||||||
|
|
||||||
|
this._onData = this._onData.bind(this);
|
||||||
|
this._onEnd = this._onEnd.bind(this);
|
||||||
|
this._onError = this._onError.bind(this);
|
||||||
|
this._onResume = this._onResume.bind(this);
|
||||||
|
|
||||||
|
readable.on('data', this._onData);
|
||||||
|
readable.on('end', this._onEnd);
|
||||||
|
readable.on('error', this._onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe(sink) {
|
||||||
|
this.sink = sink;
|
||||||
|
sink.source = this;
|
||||||
|
if (!sink.paused) {
|
||||||
|
this.resume();
|
||||||
|
}
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.paused = false;
|
||||||
|
this.readable.resume();
|
||||||
|
this._drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(err) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = err || new Error('Aborted');
|
||||||
|
this._cleanup();
|
||||||
|
if (this.sink) {
|
||||||
|
this.sink.end(this.ended);
|
||||||
|
this.sink.source = null;
|
||||||
|
this.sink = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_drain() {
|
||||||
|
// _drain is no longer needed - _onData handles all data events
|
||||||
|
// This method is kept for compatibility but does nothing
|
||||||
|
// The readable stream's 'data' event handler (_onData) is already set up in constructor
|
||||||
|
}
|
||||||
|
|
||||||
|
_onData(data) {
|
||||||
|
if (this.ended) return;
|
||||||
|
if (this.sink) {
|
||||||
|
if (this.sink.paused) {
|
||||||
|
this.paused = true;
|
||||||
|
this.readable.pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.sink.write(data);
|
||||||
|
this.paused = this.sink.paused;
|
||||||
|
} else {
|
||||||
|
this.paused = true;
|
||||||
|
this.readable.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onEnd() {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
this._cleanup();
|
||||||
|
if (this.sink) {
|
||||||
|
this.sink.end(this._endedWithError);
|
||||||
|
this.sink.source = null;
|
||||||
|
this.sink = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onError(err) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this._endedWithError = err;
|
||||||
|
this.ended = err;
|
||||||
|
this._cleanup();
|
||||||
|
if (this.sink) {
|
||||||
|
this.sink.end(err);
|
||||||
|
this.sink.source = null;
|
||||||
|
this.sink = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onResume() {
|
||||||
|
if (this.paused && !this.ended) {
|
||||||
|
this.paused = false;
|
||||||
|
this.readable.resume();
|
||||||
|
this._drain();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this.readable.removeListener('data', this._onData);
|
||||||
|
this.readable.removeListener('end', this._onEnd);
|
||||||
|
this.readable.removeListener('error', this._onError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PushSink {
|
||||||
|
constructor(writable) {
|
||||||
|
this.writable = writable;
|
||||||
|
this.source = null;
|
||||||
|
this.paused = false;
|
||||||
|
this.ended = false;
|
||||||
|
this._endedWithError = null;
|
||||||
|
this._draining = false;
|
||||||
|
|
||||||
|
this._onDrain = this._onDrain.bind(this);
|
||||||
|
this._onError = this._onError.bind(this);
|
||||||
|
|
||||||
|
writable.on('drain', this._onDrain);
|
||||||
|
writable.on('error', this._onError);
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe(sink) {
|
||||||
|
if (sink && sink.write) {
|
||||||
|
this.source = sink;
|
||||||
|
sink.source = this;
|
||||||
|
}
|
||||||
|
return sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
write(data) {
|
||||||
|
if (this.ended) return;
|
||||||
|
const ok = this.writable.write(data);
|
||||||
|
if (!ok) {
|
||||||
|
this.paused = true;
|
||||||
|
}
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
end(err) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = err || true;
|
||||||
|
this._cleanup();
|
||||||
|
if (this.writable.destroyed || this.writable.writableEnded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err) {
|
||||||
|
this.writable.destroy(err);
|
||||||
|
} else {
|
||||||
|
this.writable.end();
|
||||||
|
}
|
||||||
|
if (this.source) {
|
||||||
|
this.source.sink = null;
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(err) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = err || new Error('Aborted');
|
||||||
|
this._cleanup();
|
||||||
|
if (!this.writable.destroyed) {
|
||||||
|
this.writable.destroy(this.ended);
|
||||||
|
}
|
||||||
|
if (this.source) {
|
||||||
|
this.source.abort(this.ended);
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onDrain() {
|
||||||
|
if (this.paused && !this.ended) {
|
||||||
|
this.paused = false;
|
||||||
|
if (this.source) {
|
||||||
|
this.source.resume();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_onError(err) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this._endedWithError = err;
|
||||||
|
this.ended = err;
|
||||||
|
this._cleanup();
|
||||||
|
if (this.source) {
|
||||||
|
this.source.abort(err);
|
||||||
|
this.source.sink = null;
|
||||||
|
this.source = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cleanup() {
|
||||||
|
this.writable.removeListener('drain', this._onDrain);
|
||||||
|
this.writable.removeListener('error', this._onError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PushDuplex {
|
||||||
|
constructor(duplex) {
|
||||||
|
this.source = new PushSource(duplex);
|
||||||
|
this.sink = new PushSink(duplex);
|
||||||
|
this.paused = false;
|
||||||
|
this.ended = false;
|
||||||
|
|
||||||
|
this.sink.source = this.source;
|
||||||
|
this.source.sink = this.sink;
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe(sink) {
|
||||||
|
return this.source.pipe(sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
resume() {
|
||||||
|
this.source.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
abort(err) {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = err || new Error('Aborted');
|
||||||
|
this.source.abort(err);
|
||||||
|
this.sink.end(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function source(readableStream) {
|
||||||
|
if (!(readableStream instanceof Readable)) {
|
||||||
|
throw new Error('Expected a Node.js Readable stream');
|
||||||
|
}
|
||||||
|
return new PushSource(readableStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sink(writableStream) {
|
||||||
|
if (!(writableStream instanceof Writable)) {
|
||||||
|
throw new Error('Expected a Node.js Writable stream');
|
||||||
|
}
|
||||||
|
return new PushSink(writableStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function duplex(duplexStream) {
|
||||||
|
if (!(duplexStream instanceof Duplex)) {
|
||||||
|
throw new Error('Expected a Node.js Duplex stream');
|
||||||
|
}
|
||||||
|
return new PushDuplex(duplexStream);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "@push-stream-std/stream-to-push-stream",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Convert Node.js streams to push-streams",
|
||||||
|
"type": "module",
|
||||||
|
"main": "index.js",
|
||||||
|
"exports": {
|
||||||
|
".": "./index.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test test/",
|
||||||
|
"bench": "node bench/index.js",
|
||||||
|
"lint": "node --check index.js test/*.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"push-stream",
|
||||||
|
"stream",
|
||||||
|
"convert"
|
||||||
|
],
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"index.js",
|
||||||
|
"bench/",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"publishConfig": {
|
||||||
|
"registry": "https://hub.kl1.tenere.ai/api/packages/push-stream-std/npm/"
|
||||||
|
}
|
||||||
|
}
|
||||||
+366
@@ -0,0 +1,366 @@
|
|||||||
|
import { Readable, Writable, Duplex } from 'stream';
|
||||||
|
import { test, describe, beforeEach, afterEach } from 'node:test';
|
||||||
|
import { source, sink, duplex } from '../index.js';
|
||||||
|
|
||||||
|
function readableFromArray(arr) {
|
||||||
|
return new Readable({
|
||||||
|
objectMode: true,
|
||||||
|
read() {
|
||||||
|
if (arr.length > 0) {
|
||||||
|
this.push(arr.shift());
|
||||||
|
} else {
|
||||||
|
this.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writableToArray() {
|
||||||
|
const arr = [];
|
||||||
|
return new Writable({
|
||||||
|
objectMode: true,
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
arr.push(chunk);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stream-to-push-stream', () => {
|
||||||
|
describe('source', () => {
|
||||||
|
test('converts readable stream to push-source', () => {
|
||||||
|
const received = [];
|
||||||
|
const readable = readableFromArray([1, 2, 3, 4, 5]);
|
||||||
|
const pushSrc = source(readable);
|
||||||
|
const output = [];
|
||||||
|
|
||||||
|
pushSrc.pipe({
|
||||||
|
write(data) {
|
||||||
|
output.push(data);
|
||||||
|
},
|
||||||
|
end() {},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (output.length === 5) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Expected 5 items'));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles backpressure', () => {
|
||||||
|
const received = [];
|
||||||
|
const output = [];
|
||||||
|
let paused = true;
|
||||||
|
|
||||||
|
const readable = readableFromArray([1, 2, 3, 4, 5]);
|
||||||
|
const pushSrc = source(readable);
|
||||||
|
|
||||||
|
pushSrc.pipe({
|
||||||
|
write(data) {
|
||||||
|
output.push(data);
|
||||||
|
if (output.length === 1) {
|
||||||
|
paused = true;
|
||||||
|
this.paused = true;
|
||||||
|
} else if (output.length === 3) {
|
||||||
|
paused = false;
|
||||||
|
this.paused = false;
|
||||||
|
pushSrc.resume();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
end() {},
|
||||||
|
get paused() { return paused; },
|
||||||
|
set paused(v) { paused = v; }
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (output.length === 5) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Expected 5 items, got ' + output.length));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('propagates errors', () => {
|
||||||
|
const readable = new Readable({
|
||||||
|
read() {
|
||||||
|
this.destroy(new Error('Test error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const pushSrc = source(readable);
|
||||||
|
let errorReceived = null;
|
||||||
|
|
||||||
|
pushSrc.pipe({
|
||||||
|
write(data) {},
|
||||||
|
end(err) {
|
||||||
|
errorReceived = err;
|
||||||
|
},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorReceived && errorReceived.message === 'Test error') {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Error not propagated'));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles already-ended stream', () => {
|
||||||
|
const readable = new Readable({
|
||||||
|
read() {}
|
||||||
|
});
|
||||||
|
readable.push(null);
|
||||||
|
|
||||||
|
const pushSrc = source(readable);
|
||||||
|
let endCalled = false;
|
||||||
|
|
||||||
|
pushSrc.pipe({
|
||||||
|
write(data) {},
|
||||||
|
end() { endCalled = true; },
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (endCalled) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('End not called for already-ended stream'));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sink', () => {
|
||||||
|
test('converts writable stream to push-sink', () => {
|
||||||
|
const writable = new Writable({
|
||||||
|
objectMode: true,
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const received = [];
|
||||||
|
writable.on('data', (chunk) => received.push(chunk));
|
||||||
|
|
||||||
|
const pushSnk = sink(writable);
|
||||||
|
|
||||||
|
pushSnk.write(1);
|
||||||
|
pushSnk.write(2);
|
||||||
|
pushSnk.write(3);
|
||||||
|
pushSnk.end();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (received.length === 3) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Expected 3 items, got ' + received.length));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles backpressure from writable', () => {
|
||||||
|
let canWrite = false;
|
||||||
|
const writable = new Writable({
|
||||||
|
objectMode: true,
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
if (!canWrite) {
|
||||||
|
callback(false);
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushSnk = sink(writable);
|
||||||
|
let drainCount = 0;
|
||||||
|
|
||||||
|
writable.on('drain', () => {
|
||||||
|
drainCount++;
|
||||||
|
canWrite = true;
|
||||||
|
pushSnk.resume();
|
||||||
|
});
|
||||||
|
|
||||||
|
pushSnk.write(1);
|
||||||
|
pushSnk.write(2);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (drainCount > 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Drain not emitted'));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('propagates writable errors', () => {
|
||||||
|
const writable = new Writable({
|
||||||
|
objectMode: true,
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
callback(new Error('Write error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushSnk = sink(writable);
|
||||||
|
let errorReceived = null;
|
||||||
|
|
||||||
|
pushSnk.write(1);
|
||||||
|
|
||||||
|
writable.on('error', (err) => {
|
||||||
|
errorReceived = err;
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (errorReceived) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Error not propagated'));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('duplex', () => {
|
||||||
|
test('converts duplex stream to push-duplex', () => {
|
||||||
|
const duplexStream = new Duplex({
|
||||||
|
objectMode: true,
|
||||||
|
read() {},
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
this.push(chunk);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushDup = duplex(duplexStream);
|
||||||
|
const output = [];
|
||||||
|
|
||||||
|
pushDup.pipe({
|
||||||
|
write(data) {
|
||||||
|
output.push(data);
|
||||||
|
},
|
||||||
|
end() {},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
duplexStream.write(1, () => {
|
||||||
|
duplexStream.write(2, () => {
|
||||||
|
duplexStream.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (output.length === 2) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Expected 2 items, got ' + output.length));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles source and sink independently', () => {
|
||||||
|
const duplexStream = new Duplex({
|
||||||
|
objectMode: true,
|
||||||
|
read() {},
|
||||||
|
write(chunk, encoding, callback) {
|
||||||
|
this.push(chunk);
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushDup = duplex(duplexStream);
|
||||||
|
const sourceOutput = [];
|
||||||
|
const sinkReceived = [];
|
||||||
|
|
||||||
|
pushDup.sink.pipe({
|
||||||
|
write(data) {
|
||||||
|
sinkReceived.push(data);
|
||||||
|
},
|
||||||
|
end() {},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
pushDup.source.pipe({
|
||||||
|
write(data) {
|
||||||
|
sourceOutput.push(data);
|
||||||
|
},
|
||||||
|
end() {},
|
||||||
|
get paused() { return false; },
|
||||||
|
set paused(v) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
duplexStream.write('test');
|
||||||
|
duplexStream.push('response');
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (sinkReceived.length === 1 && sourceOutput.length === 1) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
resolve(new Error('Expected 1 item each'));
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
test('source throws for non-Readable', () => {
|
||||||
|
try {
|
||||||
|
source({});
|
||||||
|
throw new Error('Expected error');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message !== 'Expected a Node.js Readable stream') {
|
||||||
|
throw new Error('Wrong error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sink throws for non-Writable', () => {
|
||||||
|
try {
|
||||||
|
sink({});
|
||||||
|
throw new Error('Expected error');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message !== 'Expected a Node.js Writable stream') {
|
||||||
|
throw new Error('Wrong error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('duplex throws for non-Duplex', () => {
|
||||||
|
try {
|
||||||
|
duplex({});
|
||||||
|
throw new Error('Expected error');
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message !== 'Expected a Node.js Duplex stream') {
|
||||||
|
throw new Error('Wrong error: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user