@push.rocks/smartstream 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -56,67 +56,116 @@ export class SmartDuplex<TInput = any, TOutput = any> extends Duplex {
56
56
  readableStream: ReadableStream<T>
57
57
  ): SmartDuplex<T, T> {
58
58
  const smartDuplex = new SmartDuplex<T, T>({
59
- /**
60
- * this function is called whenever the stream is being read from and at the same time if nothing is enqueued
61
- * therefor it is important to always unlock the reader after reading
62
- */
63
- readFunction: async () => {
64
- const reader = readableStream.getReader();
65
- const { value, done } = await reader.read();
66
- if (value !== undefined) {
67
- smartDuplex.push(value);
68
- }
69
- reader.releaseLock();
70
- if (done) {
71
- smartDuplex.push(null);
59
+ objectMode: true,
60
+ });
61
+
62
+ // Acquire reader ONCE
63
+ const reader = readableStream.getReader();
64
+ let reading = false;
65
+
66
+ // Override _read to pull from the web reader
67
+ smartDuplex._read = function (_size: number) {
68
+ if (reading) return;
69
+ reading = true;
70
+ reader.read().then(
71
+ ({ value, done }) => {
72
+ reading = false;
73
+ if (done) {
74
+ smartDuplex.push(null);
75
+ } else {
76
+ smartDuplex.push(value);
77
+ }
78
+ },
79
+ (err) => {
80
+ reading = false;
81
+ smartDuplex.destroy(err);
72
82
  }
73
- },
83
+ );
84
+ };
85
+
86
+ // Cancel reader on destroy
87
+ smartDuplex.on('close', () => {
88
+ reader.cancel().catch(() => {});
74
89
  });
90
+
75
91
  return smartDuplex;
76
92
  }
77
93
 
78
94
  // INSTANCE
79
- private backpressuredArray: plugins.lik.BackpressuredArray<TOutput>; // an array that only takes a defined amount of items
95
+ private backpressuredArray: plugins.lik.BackpressuredArray<TOutput>;
80
96
  public options: ISmartDuplexOptions<TInput, TOutput>;
81
- private observableSubscription?: plugins.smartrx.rxjs.Subscription;
97
+ private _consumerWantsData = false;
98
+ private _readFunctionRunning = false;
99
+
82
100
  private debugLog(messageArg: string) {
83
- // optional debug log
84
101
  if (this.options.debug) {
85
102
  console.log(messageArg);
86
103
  }
87
104
  }
88
105
 
89
106
  constructor(optionsArg?: ISmartDuplexOptions<TInput, TOutput>) {
107
+ const safeOptions = optionsArg || {} as ISmartDuplexOptions<TInput, TOutput>;
90
108
  super(
91
109
  Object.assign(
92
110
  {
93
111
  highWaterMark: 1,
94
112
  },
95
- optionsArg
113
+ safeOptions
96
114
  )
97
115
  );
98
- this.options = optionsArg;
116
+ this.options = safeOptions;
99
117
  this.backpressuredArray = new plugins.lik.BackpressuredArray<TOutput>(
100
118
  this.options.highWaterMark || 1
101
119
  );
102
120
  }
103
121
 
104
- public async _read(size: number): Promise<void> {
122
+ /**
123
+ * Synchronously drains items from the backpressuredArray into the readable side.
124
+ * Stops when push() returns false (consumer is full) or array is empty.
125
+ */
126
+ private _drainBackpressuredArray(): void {
127
+ while (this.backpressuredArray.data.length > 0) {
128
+ const nextChunk = this.backpressuredArray.shift();
129
+ if (nextChunk === null) {
130
+ // EOF signal — push null to end readable side
131
+ this.push(null);
132
+ this._consumerWantsData = false;
133
+ return;
134
+ }
135
+ const canPushMore = this.push(nextChunk);
136
+ if (!canPushMore) {
137
+ this._consumerWantsData = false;
138
+ return;
139
+ }
140
+ }
141
+ }
142
+
143
+ // _read must NOT be async — Node.js ignores the return value
144
+ public _read(size: number): void {
105
145
  this.debugLog(`${this.options.name}: read was called`);
106
- if (this.options.readFunction) {
107
- await this.options.readFunction();
146
+ this._consumerWantsData = true;
147
+
148
+ // Drain any buffered items first
149
+ if (this.backpressuredArray.data.length > 0) {
150
+ this._drainBackpressuredArray();
108
151
  }
109
- await this.backpressuredArray.waitForItems();
110
- this.debugLog(`${this.options.name}: successfully waited for items.`);
111
- let canPushMore = true;
112
- while (this.backpressuredArray.data.length > 0 && canPushMore) {
113
- const nextChunk = this.backpressuredArray.shift();
114
- canPushMore = this.push(nextChunk);
152
+
153
+ // If readFunction exists and is not already running, start it
154
+ if (this.options.readFunction && !this._readFunctionRunning) {
155
+ this._readFunctionRunning = true;
156
+ this.options.readFunction().then(
157
+ () => { this._readFunctionRunning = false; },
158
+ (err) => { this._readFunctionRunning = false; this.destroy(err); }
159
+ );
115
160
  }
116
161
  }
117
162
 
118
163
  public async backpressuredPush(pushArg: TOutput) {
119
164
  const canPushMore = this.backpressuredArray.push(pushArg);
165
+ // Try to drain if the consumer wants data
166
+ if (this._consumerWantsData) {
167
+ this._drainBackpressuredArray();
168
+ }
120
169
  if (!canPushMore) {
121
170
  this.debugLog(`${this.options.name}: cannot push more`);
122
171
  await this.backpressuredArray.waitForSpace();
@@ -126,83 +175,151 @@ export class SmartDuplex<TInput = any, TOutput = any> extends Duplex {
126
175
  }
127
176
 
128
177
  private asyncWritePromiseObjectmap = new plugins.lik.ObjectMap<Promise<any>>();
129
- // Ensure the _write method types the chunk as TInput and encodes TOutput
130
- public async _write(chunk: TInput, encoding: string, callback: (error?: Error | null) => void) {
178
+
179
+ // _write must NOT be async Node.js ignores the return value
180
+ public _write(chunk: TInput, encoding: string, callback: (error?: Error | null) => void) {
131
181
  if (!this.options.writeFunction) {
132
182
  return callback(new Error('No stream function provided'));
133
183
  }
134
184
 
185
+ let callbackCalled = false;
186
+ const safeCallback = (err?: Error | null) => {
187
+ if (!callbackCalled) {
188
+ callbackCalled = true;
189
+ callback(err);
190
+ }
191
+ };
192
+
135
193
  let isTruncated = false;
136
194
  const tools: IStreamTools = {
137
195
  truncate: () => {
138
- this.push(null);
139
196
  isTruncated = true;
140
- callback();
197
+ safeCallback();
198
+ this.push(null);
141
199
  },
142
200
  push: async (pushArg: TOutput) => {
143
201
  return await this.backpressuredPush(pushArg);
144
202
  },
145
203
  };
146
204
 
147
- try {
148
- const writeDeferred = plugins.smartpromise.defer();
149
- this.asyncWritePromiseObjectmap.add(writeDeferred.promise);
150
- const modifiedChunk = await this.options.writeFunction(chunk, tools);
151
- if (isTruncated) {
152
- return;
153
- }
154
- if (modifiedChunk) {
155
- await tools.push(modifiedChunk);
156
- }
157
- callback();
158
- writeDeferred.resolve();
159
- writeDeferred.promise.then(() => {
205
+ const writeDeferred = plugins.smartpromise.defer();
206
+ this.asyncWritePromiseObjectmap.add(writeDeferred.promise);
207
+
208
+ this.options.writeFunction(chunk, tools).then(
209
+ (modifiedChunk) => {
210
+ if (isTruncated) {
211
+ writeDeferred.resolve();
212
+ this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
213
+ return;
214
+ }
215
+ const finish = () => {
216
+ safeCallback();
217
+ writeDeferred.resolve();
218
+ this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
219
+ };
220
+ if (modifiedChunk !== undefined && modifiedChunk !== null) {
221
+ this.backpressuredPush(modifiedChunk).then(finish, (err) => {
222
+ safeCallback(err);
223
+ writeDeferred.resolve();
224
+ this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
225
+ });
226
+ } else {
227
+ finish();
228
+ }
229
+ },
230
+ (err) => {
231
+ safeCallback(err);
232
+ writeDeferred.resolve();
160
233
  this.asyncWritePromiseObjectmap.remove(writeDeferred.promise);
161
- });
162
- } catch (err) {
163
- callback(err);
164
- }
234
+ }
235
+ );
165
236
  }
166
237
 
167
- public async _final(callback: (error?: Error | null) => void) {
168
- await Promise.all(this.asyncWritePromiseObjectmap.getArray());
169
- if (this.options.finalFunction) {
170
- const tools: IStreamTools = {
171
- truncate: () => callback(),
172
- push: async (pipeObject) => {
173
- return this.backpressuredArray.push(pipeObject);
174
- },
175
- };
238
+ // _final must NOT be async Node.js ignores the return value
239
+ public _final(callback: (error?: Error | null) => void) {
240
+ let callbackCalled = false;
241
+ const safeCallback = (err?: Error | null) => {
242
+ if (!callbackCalled) {
243
+ callbackCalled = true;
244
+ callback(err);
245
+ }
246
+ };
176
247
 
177
- try {
178
- const finalChunk = await this.options.finalFunction(tools);
179
- if (finalChunk) {
180
- this.backpressuredArray.push(finalChunk);
181
- }
182
- } catch (err) {
248
+ Promise.all(this.asyncWritePromiseObjectmap.getArray()).then(() => {
249
+ if (this.options.finalFunction) {
250
+ const tools: IStreamTools = {
251
+ truncate: () => safeCallback(),
252
+ push: async (pipeObject) => {
253
+ return await this.backpressuredPush(pipeObject);
254
+ },
255
+ };
256
+
257
+ this.options.finalFunction(tools).then(
258
+ (finalChunk) => {
259
+ const pushNull = () => {
260
+ this.backpressuredArray.push(null);
261
+ if (this._consumerWantsData) {
262
+ this._drainBackpressuredArray();
263
+ }
264
+ safeCallback();
265
+ };
266
+
267
+ if (finalChunk !== undefined && finalChunk !== null) {
268
+ this.backpressuredPush(finalChunk).then(pushNull, (err) => {
269
+ safeCallback(err);
270
+ });
271
+ } else {
272
+ pushNull();
273
+ }
274
+ },
275
+ (err) => {
276
+ this.backpressuredArray.push(null);
277
+ if (this._consumerWantsData) {
278
+ this._drainBackpressuredArray();
279
+ }
280
+ safeCallback(err);
281
+ }
282
+ );
283
+ } else {
183
284
  this.backpressuredArray.push(null);
184
- callback(err);
185
- return;
285
+ if (this._consumerWantsData) {
286
+ this._drainBackpressuredArray();
287
+ }
288
+ safeCallback();
186
289
  }
187
- }
188
- this.backpressuredArray.push(null);
189
- callback();
290
+ }, (err) => {
291
+ safeCallback(err);
292
+ });
190
293
  }
191
294
 
192
295
  public async getWebStreams(): Promise<{ readable: ReadableStream; writable: WritableStream }> {
193
296
  const duplex = this;
297
+ let readableClosed = false;
298
+
194
299
  const readable = new ReadableStream({
195
300
  start(controller) {
196
- duplex.on('readable', () => {
301
+ const onReadable = () => {
197
302
  let chunk;
198
303
  while (null !== (chunk = duplex.read())) {
199
304
  controller.enqueue(chunk);
200
305
  }
201
- });
306
+ };
202
307
 
203
- duplex.on('end', () => {
204
- controller.close();
205
- });
308
+ const onEnd = () => {
309
+ if (!readableClosed) {
310
+ readableClosed = true;
311
+ controller.close();
312
+ }
313
+ cleanup();
314
+ };
315
+
316
+ const cleanup = () => {
317
+ duplex.removeListener('readable', onReadable);
318
+ duplex.removeListener('end', onEnd);
319
+ };
320
+
321
+ duplex.on('readable', onReadable);
322
+ duplex.on('end', onEnd);
206
323
  },
207
324
  cancel(reason) {
208
325
  duplex.destroy(new Error(reason));
@@ -212,22 +329,38 @@ export class SmartDuplex<TInput = any, TOutput = any> extends Duplex {
212
329
  const writable = new WritableStream({
213
330
  write(chunk) {
214
331
  return new Promise<void>((resolve, reject) => {
332
+ let resolved = false;
333
+ const onDrain = () => {
334
+ if (!resolved) {
335
+ resolved = true;
336
+ resolve();
337
+ }
338
+ };
339
+
215
340
  const isBackpressured = !duplex.write(chunk, (error) => {
216
341
  if (error) {
217
- reject(error);
218
- } else {
342
+ if (!resolved) {
343
+ resolved = true;
344
+ duplex.removeListener('drain', onDrain);
345
+ reject(error);
346
+ }
347
+ } else if (!isBackpressured && !resolved) {
348
+ resolved = true;
219
349
  resolve();
220
350
  }
221
351
  });
222
352
 
223
353
  if (isBackpressured) {
224
- duplex.once('drain', resolve);
354
+ duplex.once('drain', onDrain);
225
355
  }
226
356
  });
227
357
  },
228
358
  close() {
229
359
  return new Promise<void>((resolve, reject) => {
230
- duplex.end(resolve);
360
+ duplex.end((err: Error | null) => {
361
+ if (err) reject(err);
362
+ else resolve();
363
+ });
231
364
  });
232
365
  },
233
366
  abort(reason) {
@@ -6,8 +6,11 @@ export class StreamIntake<T> extends plugins.stream.Readable {
6
6
  const intakeStream = new StreamIntake<U>(options);
7
7
 
8
8
  if (inputStream instanceof plugins.stream.Readable) {
9
- inputStream.on('data', (chunk: U) => {
10
- intakeStream.pushData(chunk);
9
+ inputStream.on('readable', () => {
10
+ let chunk: U;
11
+ while (null !== (chunk = inputStream.read() as U)) {
12
+ intakeStream.pushData(chunk);
13
+ }
11
14
  });
12
15
 
13
16
  inputStream.on('end', () => {
@@ -1,8 +1,5 @@
1
1
  import * as plugins from './smartstream.plugins.js';
2
2
 
3
- // interfaces
4
- import { Transform } from 'stream';
5
-
6
3
  export interface IErrorFunction {
7
4
  (err: Error): any;
8
5
  }
@@ -82,15 +79,17 @@ export class StreamWrapper {
82
79
 
83
80
  this.streamStartedDeferred.resolve();
84
81
 
85
- finalStream.on('end', () => {
86
- done.resolve();
87
- });
88
- finalStream.on('close', () => {
89
- done.resolve();
90
- });
91
- finalStream.on('finish', () => {
92
- done.resolve();
93
- });
82
+ let resolved = false;
83
+ const safeResolve = () => {
84
+ if (!resolved) {
85
+ resolved = true;
86
+ done.resolve();
87
+ }
88
+ };
89
+
90
+ finalStream.on('end', safeResolve);
91
+ finalStream.on('close', safeResolve);
92
+ finalStream.on('finish', safeResolve);
94
93
  return done.promise;
95
94
  }
96
95
  }
@@ -1,7 +1,7 @@
1
1
  import * as plugins from './smartstream.plugins.js';
2
2
 
3
3
  /**
4
- * Creates a Web ReadableStream from a file.
4
+ * Creates a Web ReadableStream from a file using pull-based backpressure.
5
5
  *
6
6
  * @param filePath - The path to the file to be read
7
7
  * @returns A Web ReadableStream that reads the file in chunks
@@ -11,23 +11,53 @@ export function createWebReadableStreamFromFile(filePath: string): ReadableStrea
11
11
 
12
12
  return new ReadableStream({
13
13
  start(controller) {
14
- // When data is available, enqueue it into the Web ReadableStream
15
- fileStream.on('data', (chunk) => {
16
- controller.enqueue(chunk as Uint8Array);
14
+ fileStream.on('error', (err) => {
15
+ controller.error(err);
17
16
  });
18
17
 
19
- // When the file stream ends, close the Web ReadableStream
20
18
  fileStream.on('end', () => {
21
19
  controller.close();
22
20
  });
23
21
 
24
- // If there's an error, error the Web ReadableStream
25
- fileStream.on('error', (err) => {
26
- controller.error(err);
22
+ // Pause immediately pull() will drive reads
23
+ fileStream.pause();
24
+ },
25
+ pull(controller) {
26
+ return new Promise<void>((resolve, reject) => {
27
+ const chunk = fileStream.read();
28
+ if (chunk !== null) {
29
+ controller.enqueue(chunk as Uint8Array);
30
+ resolve();
31
+ return;
32
+ }
33
+ // No data available yet — wait for 'readable' or 'end'
34
+ const onReadable = () => {
35
+ cleanup();
36
+ const data = fileStream.read();
37
+ if (data !== null) {
38
+ controller.enqueue(data as Uint8Array);
39
+ }
40
+ resolve();
41
+ };
42
+ const onEnd = () => {
43
+ cleanup();
44
+ resolve();
45
+ };
46
+ const onError = (err: Error) => {
47
+ cleanup();
48
+ reject(err);
49
+ };
50
+ const cleanup = () => {
51
+ fileStream.removeListener('readable', onReadable);
52
+ fileStream.removeListener('end', onEnd);
53
+ fileStream.removeListener('error', onError);
54
+ };
55
+ fileStream.once('readable', onReadable);
56
+ fileStream.once('end', onEnd);
57
+ fileStream.once('error', onError);
27
58
  });
28
59
  },
29
60
  cancel() {
30
- // If the Web ReadableStream is canceled, destroy the file stream
31
61
  fileStream.destroy();
32
62
  }
33
63
  });
@@ -43,23 +73,25 @@ export function convertWebReadableToNodeReadable(webStream: ReadableStream<Uint8
43
73
  const reader = webStream.getReader();
44
74
 
45
75
  return new plugins.stream.Readable({
46
- async read() {
47
- try {
48
- const { value, done } = await reader.read();
49
- if (done) {
50
- this.push(null); // Signal end of stream
51
- } else {
52
- this.push(Buffer.from(value)); // Convert Uint8Array to Buffer for Node.js Readable
76
+ read() {
77
+ reader.read().then(
78
+ ({ value, done }) => {
79
+ if (done) {
80
+ this.push(null);
81
+ } else {
82
+ this.push(Buffer.from(value));
83
+ }
84
+ },
85
+ (err) => {
86
+ this.destroy(err);
53
87
  }
54
- } catch (err) {
55
- this.destroy(err); // Handle errors by destroying the stream
56
- }
88
+ );
57
89
  }
58
90
  });
59
91
  }
60
92
 
61
93
  /**
62
- * Converts a Node.js Readable stream to a Web ReadableStream.
94
+ * Converts a Node.js Readable stream to a Web ReadableStream using pull-based backpressure.
63
95
  *
64
96
  * @param nodeStream - The Node.js Readable stream to convert
65
97
  * @returns A Web ReadableStream that reads data from the Node.js Readable stream
@@ -67,16 +99,50 @@ export function convertWebReadableToNodeReadable(webStream: ReadableStream<Uint8
67
99
  export function convertNodeReadableToWebReadable(nodeStream: plugins.stream.Readable): ReadableStream<Uint8Array> {
68
100
  return new ReadableStream({
69
101
  start(controller) {
70
- nodeStream.on('data', (chunk) => {
71
- controller.enqueue(new Uint8Array(chunk));
102
+ nodeStream.on('error', (err) => {
103
+ controller.error(err);
72
104
  });
73
105
 
74
106
  nodeStream.on('end', () => {
75
107
  controller.close();
76
108
  });
77
109
 
78
- nodeStream.on('error', (err) => {
79
- controller.error(err);
110
+ // Pause immediately — pull() will drive reads
111
+ nodeStream.pause();
112
+ },
113
+ pull(controller) {
114
+ return new Promise<void>((resolve, reject) => {
115
+ const chunk = nodeStream.read();
116
+ if (chunk !== null) {
117
+ controller.enqueue(new Uint8Array(chunk));
118
+ resolve();
119
+ return;
120
+ }
121
+ // No data available yet — wait for 'readable' or 'end'
122
+ const onReadable = () => {
123
+ cleanup();
124
+ const data = nodeStream.read();
125
+ if (data !== null) {
126
+ controller.enqueue(new Uint8Array(data));
127
+ }
128
+ resolve();
129
+ };
130
+ const onEnd = () => {
131
+ cleanup();
132
+ resolve();
133
+ };
134
+ const onError = (err: Error) => {
135
+ cleanup();
136
+ reject(err);
137
+ };
138
+ const cleanup = () => {
139
+ nodeStream.removeListener('readable', onReadable);
140
+ nodeStream.removeListener('end', onEnd);
141
+ nodeStream.removeListener('error', onError);
142
+ };
143
+ nodeStream.once('readable', onReadable);
144
+ nodeStream.once('end', onEnd);
145
+ nodeStream.once('error', onError);
80
146
  });
81
147
  },
82
148
  cancel() {
@@ -95,19 +161,23 @@ export function convertWebWritableToNodeWritable(webWritable: WritableStream<Uin
95
161
  const writer = webWritable.getWriter();
96
162
 
97
163
  return new plugins.stream.Writable({
98
- async write(chunk, encoding, callback) {
99
- try {
100
- await writer.write(new Uint8Array(chunk));
101
- callback();
102
- } catch (err) {
103
- callback(err);
104
- }
164
+ write(chunk, encoding, callback) {
165
+ writer.write(new Uint8Array(chunk)).then(
166
+ () => callback(),
167
+ (err) => callback(err)
168
+ );
105
169
  },
106
170
  final(callback) {
107
171
  writer.close().then(() => callback()).catch(callback);
108
172
  },
109
173
  destroy(err, callback) {
110
- writer.abort(err).then(() => callback(err)).catch(callback);
174
+ if (err) {
175
+ writer.abort(err).then(() => callback(err)).catch(() => callback(err));
176
+ } else {
177
+ // Clean destroy — just release the lock
178
+ writer.releaseLock();
179
+ callback(null);
180
+ }
111
181
  }
112
182
  });
113
183
  }
@@ -133,7 +203,7 @@ export function convertNodeWritableToWebWritable(nodeWritable: plugins.stream.Wr
133
203
  },
134
204
  close() {
135
205
  return new Promise((resolve, reject) => {
136
- nodeWritable.end((err) => {
206
+ nodeWritable.end((err: Error | null) => {
137
207
  if (err) {
138
208
  reject(err);
139
209
  } else {
@@ -143,9 +213,7 @@ export function convertNodeWritableToWebWritable(nodeWritable: plugins.stream.Wr
143
213
  });
144
214
  },
145
215
  abort(reason) {
146
- return new Promise((resolve, reject) => {
147
- nodeWritable.destroy(reason);
148
- });
216
+ nodeWritable.destroy(reason instanceof Error ? reason : new Error(String(reason)));
149
217
  }
150
218
  });
151
- }
219
+ }
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@push.rocks/smartstream',
6
- version: '3.3.0',
6
+ version: '3.4.0',
7
7
  description: 'A library to simplify the creation and manipulation of Node.js streams, providing utilities for handling transform, duplex, and readable/writable streams effectively in TypeScript.'
8
8
  }