@push.rocks/smartstream 3.3.0 → 3.4.2

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.
@@ -7,11 +7,11 @@ export interface IStreamTools {
7
7
  }
8
8
 
9
9
  export interface IStreamWriteFunction<T, rT> {
10
- (chunkArg: T, toolsArg: IStreamTools): Promise<rT>;
10
+ (chunkArg: T, toolsArg: IStreamTools): Promise<rT | void | null | undefined>;
11
11
  }
12
12
 
13
13
  export interface IStreamFinalFunction<rT> {
14
- (toolsArg: IStreamTools): Promise<rT>;
14
+ (toolsArg: IStreamTools): Promise<rT | void | null | undefined>;
15
15
  }
16
16
 
17
17
  export interface ISmartDuplexOptions<TInput, TOutput> extends DuplexOptions {
@@ -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 | null>;
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;
99
- this.backpressuredArray = new plugins.lik.BackpressuredArray<TOutput>(
116
+ this.options = safeOptions;
117
+ this.backpressuredArray = new plugins.lik.BackpressuredArray<TOutput | null>(
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
  }
@@ -62,7 +59,7 @@ export class StreamWrapper {
62
59
  }
63
60
 
64
61
  // combine the stream
65
- let finalStream = null;
62
+ let finalStream: plugins.stream.Duplex | null = null;
66
63
  let firstIteration: boolean = true;
67
64
  for (const stream of streamExecutionArray) {
68
65
  if (firstIteration === true) {
@@ -74,23 +71,30 @@ export class StreamWrapper {
74
71
  for (const customEventObject of this.customEventObjectArray) {
75
72
  stream.on(customEventObject.eventName, customEventObject.eventFunction);
76
73
  }
77
- if (!firstIteration) {
74
+ if (!firstIteration && finalStream) {
78
75
  finalStream = finalStream.pipe(stream);
79
76
  }
80
77
  firstIteration = false;
81
78
  }
82
79
 
80
+ if (!finalStream) {
81
+ done.resolve();
82
+ return done.promise;
83
+ }
84
+
83
85
  this.streamStartedDeferred.resolve();
84
86
 
85
- finalStream.on('end', () => {
86
- done.resolve();
87
- });
88
- finalStream.on('close', () => {
89
- done.resolve();
90
- });
91
- finalStream.on('finish', () => {
92
- done.resolve();
93
- });
87
+ let resolved = false;
88
+ const safeResolve = () => {
89
+ if (!resolved) {
90
+ resolved = true;
91
+ done.resolve();
92
+ }
93
+ };
94
+
95
+ finalStream.on('end', safeResolve);
96
+ finalStream.on('close', safeResolve);
97
+ finalStream.on('finish', safeResolve);
94
98
  return done.promise;
95
99
  }
96
100
  }
@@ -1,4 +1,4 @@
1
- import { type TransformOptions } from 'stream';
1
+ import { type DuplexOptions } from 'stream';
2
2
  import { SmartDuplex } from './smartstream.classes.smartduplex.js';
3
3
 
4
4
  export interface AsyncTransformFunction<TInput, TOutput> {
@@ -7,7 +7,7 @@ export interface AsyncTransformFunction<TInput, TOutput> {
7
7
 
8
8
  export function createTransformFunction<TInput, TOutput>(
9
9
  asyncFunction: AsyncTransformFunction<TInput, TOutput>,
10
- options?: TransformOptions
10
+ options?: DuplexOptions
11
11
  ): SmartDuplex {
12
12
  const smartDuplexStream = new SmartDuplex({
13
13
  ...options,