@gjsify/stream 0.1.7 → 0.1.8

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.
@@ -0,0 +1,157 @@
1
+ // Legacy `.call(this)` + `util.inherits(Sub, Parent)` compatibility tests.
2
+ //
3
+ // Ported from refs/node-test/parallel/test-util-inherits.js
4
+ // Original: MIT license, Node.js contributors
5
+ // Modifications: adapted to @gjsify/unit and extended to cover all six stream
6
+ // classes that are wrapped by `makeCallable` in `./callable.ts`.
7
+ //
8
+ // Regression coverage for the npm `send` (express.static) bug and for our own
9
+ // `@gjsify/crypto` `Hash.copy()` path which both do `Stream.call(this)` /
10
+ // `Transform.call(copy)` on pre-ES2015 CJS-style constructors.
11
+
12
+ import { describe, it, expect } from '@gjsify/unit';
13
+ import { Stream, Readable, Writable, Duplex, Transform, PassThrough } from 'node:stream';
14
+ import { inherits } from 'node:util';
15
+
16
+ export default async () => {
17
+ await describe('makeCallable: new invocation still works', async () => {
18
+ await it('new Stream() returns a valid instance', async () => {
19
+ const s = new Stream();
20
+ expect(typeof (s as any).pipe).toBe('function');
21
+ expect(s instanceof Stream).toBe(true);
22
+ });
23
+
24
+ await it('new Readable() initialises readable state', async () => {
25
+ const r = new Readable({ read() {} });
26
+ expect(r.readable).toBe(true);
27
+ expect(r instanceof Readable).toBe(true);
28
+ expect(r instanceof Stream).toBe(true);
29
+ });
30
+
31
+ await it('new Transform() initialises both readable + writable state', async () => {
32
+ const t = new Transform({ transform(chunk, _enc, cb) { cb(null, chunk); } });
33
+ expect(t.readable).toBe(true);
34
+ expect(t.writable).toBe(true);
35
+ expect(t instanceof Transform).toBe(true);
36
+ expect(t instanceof Duplex).toBe(true);
37
+ expect(t instanceof Readable).toBe(true);
38
+ });
39
+
40
+ await it('Stream.prototype is accessible via the wrapper', async () => {
41
+ expect(typeof (Stream as any).prototype).toBe('object');
42
+ expect(typeof (Stream as any).prototype.pipe).toBe('function');
43
+ });
44
+ });
45
+
46
+ await describe('makeCallable: legacy Stream.call(this) + util.inherits', async () => {
47
+ await it('Stream.call(plainObject) assigns EventEmitter bookkeeping', async () => {
48
+ // Plain object promoted to a Stream via .call()
49
+ const plain: any = Object.create(Stream.prototype);
50
+ (Stream as any).call(plain);
51
+ // EventEmitter state is now present
52
+ plain.on('ping', function(this: any, msg: string) { this.received = msg; });
53
+ plain.emit('ping', 'hi');
54
+ expect(plain.received).toBe('hi');
55
+ });
56
+
57
+ await it('legacy subclass pattern (function + inherits + .call) works', async () => {
58
+ function SubStream(this: any) {
59
+ (Stream as any).call(this);
60
+ this.foo = 42;
61
+ }
62
+ inherits(SubStream as any, Stream as any);
63
+ (SubStream.prototype as any).greet = function() { return 'hello'; };
64
+
65
+ const s: any = new (SubStream as any)();
66
+ expect(s.foo).toBe(42);
67
+ expect(s.greet()).toBe('hello');
68
+ // Inherited from Stream.prototype
69
+ expect(typeof s.pipe).toBe('function');
70
+ expect(s instanceof Stream).toBe(true);
71
+ });
72
+
73
+ await it('multi-level inherits (A.call -> B.call -> C) works', async () => {
74
+ function A(this: any) {
75
+ (Stream as any).call(this);
76
+ this._a = 'a';
77
+ }
78
+ inherits(A as any, Stream as any);
79
+ (A.prototype as any).a = function() { return this._a; };
80
+
81
+ function B(this: any) {
82
+ (A as any).call(this);
83
+ this._b = 'b';
84
+ }
85
+ inherits(B as any, A as any);
86
+ (B.prototype as any).b = function() { return this._b; };
87
+
88
+ const b: any = new (B as any)();
89
+ expect(b.a()).toBe('a');
90
+ expect(b.b()).toBe('b');
91
+ expect(b instanceof A).toBe(true);
92
+ expect(b instanceof Stream).toBe(true);
93
+ });
94
+ });
95
+
96
+ await describe('makeCallable: Readable / Writable / Transform .call(this)', async () => {
97
+ await it('Readable.call(plain) sets up readable state', async () => {
98
+ const r: any = Object.create(Readable.prototype);
99
+ (Readable as any).call(r, { read() {} });
100
+ expect(r.readable).toBe(true);
101
+ expect(typeof r.readableHighWaterMark).toBe('number');
102
+ // pipe is inherited from Stream.prototype through Readable.prototype
103
+ expect(typeof r.pipe).toBe('function');
104
+ });
105
+
106
+ await it('Writable.call(plain) sets up writable state', async () => {
107
+ const w: any = Object.create(Writable.prototype);
108
+ (Writable as any).call(w, { write(_c: any, _e: any, cb: any) { cb(); } });
109
+ expect(w.writable).toBe(true);
110
+ expect(typeof w.writableHighWaterMark).toBe('number');
111
+ });
112
+
113
+ await it('Transform.call(plain) sets up both readable and writable state', async () => {
114
+ // This is the exact pattern used by @gjsify/crypto Hash.copy():
115
+ // const copy = Object.create(Hash.prototype);
116
+ // Transform.call(copy);
117
+ const copy: any = Object.create(Transform.prototype);
118
+ (Transform as any).call(copy);
119
+ expect(copy.readable).toBe(true);
120
+ expect(copy.writable).toBe(true);
121
+ // The Transform should be usable after .call() initialization —
122
+ // field initializers from all ancestors ran on copy.
123
+ expect(typeof copy.readableHighWaterMark).toBe('number');
124
+ expect(typeof copy.writableHighWaterMark).toBe('number');
125
+ });
126
+
127
+ await it('PassThrough.call(plain) inherits the full chain', async () => {
128
+ const pt: any = Object.create(PassThrough.prototype);
129
+ (PassThrough as any).call(pt);
130
+ expect(pt.readable).toBe(true);
131
+ expect(pt.writable).toBe(true);
132
+ });
133
+ });
134
+
135
+ await describe('makeCallable: integration with util.inherits', async () => {
136
+ await it('inherits(Sub, Readable) + Readable.call(this) yields a working Readable subclass', async () => {
137
+ function SimpleSrc(this: any, items: number[]) {
138
+ (Readable as any).call(this, { objectMode: true });
139
+ this._items = items;
140
+ }
141
+ inherits(SimpleSrc as any, Readable as any);
142
+ (SimpleSrc.prototype as any)._read = function() {
143
+ const item = this._items.shift();
144
+ this.push(item !== undefined ? item : null);
145
+ };
146
+
147
+ const src: any = new (SimpleSrc as any)([1, 2, 3]);
148
+ const out: number[] = [];
149
+ await new Promise<void>((resolve, reject) => {
150
+ src.on('data', (chunk: number) => out.push(chunk));
151
+ src.on('end', resolve);
152
+ src.on('error', reject);
153
+ });
154
+ expect(out).toEqualArray([1, 2, 3]);
155
+ });
156
+ });
157
+ };
@@ -0,0 +1,3 @@
1
+ // Re-exported from @gjsify/utils — second consumer (events) triggered promotion.
2
+ // See packages/gjs/utils/src/callable.ts for implementation and comments.
3
+ export { makeCallable } from '@gjsify/utils';
@@ -33,13 +33,13 @@ export default async () => {
33
33
  await describe('json', async () => {
34
34
  await it('should consume stream as JSON', async () => {
35
35
  const stream = Readable.from(['{"key":', '"value"}']);
36
- const result = await json(stream);
36
+ const result = await json(stream) as { key: string };
37
37
  expect(result.key).toBe('value');
38
38
  });
39
39
 
40
40
  await it('should parse JSON array', async () => {
41
41
  const stream = Readable.from(['[1,2,3]']);
42
- const result = await json(stream);
42
+ const result = await json(stream) as number[];
43
43
  expect(Array.isArray(result)).toBe(true);
44
44
  expect(result.length).toBe(3);
45
45
  expect(result[0]).toBe(1);
package/src/index.ts CHANGED
@@ -25,6 +25,16 @@ export function setDefaultHighWaterMark(objectMode: boolean, value: number): voi
25
25
  }
26
26
  }
27
27
 
28
+ /** Validate a named high-water-mark option and throw ERR_INVALID_ARG_VALUE on NaN/non-number. */
29
+ function validateHighWaterMark(name: string, value: unknown): void {
30
+ if (value === undefined) return;
31
+ if (typeof value !== 'number' || Number.isNaN(value)) {
32
+ const err = new TypeError(`The value of "${name}" is invalid. Received ${value}`);
33
+ (err as any).code = 'ERR_INVALID_ARG_VALUE';
34
+ throw err;
35
+ }
36
+ }
37
+
28
38
  // ---- Types ----
29
39
 
30
40
  /** Base options accepted by the Stream constructor (superset used by subclass options). */
@@ -48,14 +58,10 @@ interface StreamLike extends EventEmitter {
48
58
  /** Tracked pipe destination for unpipe support. */
49
59
  interface PipeState {
50
60
  dest: Writable;
51
- ondata: (chunk: unknown) => void;
52
- ondrain: () => void;
53
- onend: () => void;
54
61
  cleanup: () => void;
55
- doEnd: boolean;
56
62
  }
57
63
 
58
- export class Stream extends EventEmitter {
64
+ class Stream_ extends EventEmitter {
59
65
  constructor(opts?: StreamOptions) {
60
66
  super(opts);
61
67
  }
@@ -64,44 +70,74 @@ export class Stream extends EventEmitter {
64
70
  const source = this as unknown as Readable;
65
71
  const doEnd = options?.end !== false;
66
72
 
73
+ // Drain listener is added lazily only when backpressure occurs.
74
+ let drainListenerAdded = false;
75
+ const ondrain = () => {
76
+ drainListenerAdded = false;
77
+ destination.removeListener('drain', ondrain);
78
+ if (typeof (source as StreamLike).resume === 'function') {
79
+ (source as StreamLike).resume!();
80
+ }
81
+ };
82
+
67
83
  const ondata = (chunk: unknown) => {
68
84
  if (destination.writable) {
69
85
  if (destination.write(chunk) === false && typeof (source as StreamLike).pause === 'function') {
70
86
  (source as StreamLike).pause!();
87
+ if (!drainListenerAdded) {
88
+ drainListenerAdded = true;
89
+ destination.on('drain', ondrain);
90
+ }
71
91
  }
72
92
  }
73
93
  };
74
94
 
75
95
  source.on('data', ondata);
76
96
 
77
- const ondrain = () => {
78
- if (typeof (source as StreamLike).resume === 'function') {
79
- (source as StreamLike).resume!();
80
- }
81
- };
82
- destination.on('drain', ondrain);
97
+ let didEnd = false;
83
98
 
84
99
  const onend = () => {
100
+ if (didEnd) return;
101
+ didEnd = true;
102
+ if (doEnd) destination.end();
103
+ };
104
+
105
+ const onclose = () => {
106
+ if (didEnd) return;
107
+ didEnd = true;
85
108
  if (doEnd) {
86
- destination.end();
109
+ // Modern Readable streams (Readable_) do NOT destroy dest on source close —
110
+ // only call dest.end/destroy for legacy Stream objects (no Readable_ prototype).
111
+ if (!(source instanceof Readable) && typeof (destination as any).destroy === 'function') {
112
+ (destination as any).destroy();
113
+ }
87
114
  }
88
115
  };
116
+
89
117
  if (doEnd) {
90
118
  source.on('end', onend);
119
+ source.on('close', onclose);
91
120
  }
92
121
 
93
122
  const cleanup = () => {
94
123
  source.removeListener('data', ondata);
95
- destination.removeListener('drain', ondrain);
124
+ if (drainListenerAdded) destination.removeListener('drain', ondrain);
96
125
  source.removeListener('end', onend);
126
+ source.removeListener('close', onclose);
127
+ // Self-remove from both end and close
128
+ source.removeListener('end', cleanup);
129
+ source.removeListener('close', cleanup);
130
+ destination.removeListener('close', cleanup);
97
131
  };
98
132
 
133
+ source.on('end', cleanup);
99
134
  source.on('close', cleanup);
100
135
  destination.on('close', cleanup);
101
136
 
102
137
  // Track piped destinations for unpipe
103
138
  if (source instanceof Readable) {
104
- source._pipeDests.push({ dest: destination, ondata, ondrain, onend, cleanup, doEnd });
139
+ source._pipeDests.push({ dest: destination, cleanup });
140
+ (source as any)._readableState.pipes.push(destination);
105
141
  }
106
142
 
107
143
  destination.emit('pipe', source);
@@ -111,7 +147,7 @@ export class Stream extends EventEmitter {
111
147
 
112
148
  // ---- Readable ----
113
149
 
114
- export class Readable extends Stream {
150
+ class Readable_ extends Stream_ {
115
151
  readable = true;
116
152
  readableFlowing: boolean | null = null;
117
153
  readableLength = 0;
@@ -126,7 +162,7 @@ export class Readable extends Stream {
126
162
  _pipeDests: PipeState[] = [];
127
163
 
128
164
  private _buffer: unknown[] = [];
129
- private _readableState = { ended: false, endEmitted: false, reading: false, constructed: true };
165
+ _readableState = { ended: false, endEmitted: false, reading: false, constructed: true, highWaterMark: 0, objectMode: false, pipes: [] as any[] };
130
166
  private _readablePending = false;
131
167
  private _readImpl: ((size: number) => void) | undefined;
132
168
  private _destroyImpl: ((error: Error | null, cb: (error?: Error | null) => void) => void) | undefined;
@@ -137,6 +173,8 @@ export class Readable extends Stream {
137
173
  this.readableHighWaterMark = opts?.highWaterMark ?? getDefaultHighWaterMark(opts?.objectMode ?? false);
138
174
  this.readableEncoding = opts?.encoding ?? null;
139
175
  this.readableObjectMode = opts?.objectMode ?? false;
176
+ this._readableState.highWaterMark = this.readableHighWaterMark;
177
+ this._readableState.objectMode = this.readableObjectMode;
140
178
  if (opts?.read) this._readImpl = opts.read;
141
179
  if (opts?.destroy) this._destroyImpl = opts.destroy;
142
180
  if (opts?.construct) this._constructImpl = opts.construct;
@@ -275,6 +313,22 @@ export class Readable extends Stream {
275
313
  return false;
276
314
  }
277
315
 
316
+ // Validate chunk type for non-objectMode streams (Node.js ERR_INVALID_ARG_TYPE).
317
+ // Accept string, Buffer, or any ArrayBufferView — the latter covers Uint8Array
318
+ // and TypedArrays from any realm (GJS vs host bundle), avoiding cross-realm
319
+ // `instanceof Uint8Array` mismatches that would otherwise reject real Buffers.
320
+ if (!this.readableObjectMode) {
321
+ const isValid = typeof chunk === 'string' || ArrayBuffer.isView(chunk);
322
+ if (!isValid) {
323
+ const err = Object.assign(
324
+ new TypeError(`Invalid non-string/buffer chunk type: ${typeof chunk}`),
325
+ { code: 'ERR_INVALID_ARG_TYPE' }
326
+ );
327
+ nextTick(() => this.emit('error', err));
328
+ return false;
329
+ }
330
+ }
331
+
278
332
  this._buffer.push(chunk);
279
333
  this.readableLength += this.readableObjectMode ? 1 : (chunk.length ?? 1);
280
334
 
@@ -410,6 +464,7 @@ export class Readable extends Stream {
410
464
  state.dest.emit('unpipe', this);
411
465
  }
412
466
  this._pipeDests = [];
467
+ this._readableState.pipes = [];
413
468
  this.readableFlowing = false;
414
469
  } else {
415
470
  const idx = this._pipeDests.findIndex(s => s.dest === destination);
@@ -417,6 +472,8 @@ export class Readable extends Stream {
417
472
  const state = this._pipeDests[idx];
418
473
  state.cleanup();
419
474
  this._pipeDests.splice(idx, 1);
475
+ const pipeIdx = this._readableState.pipes.indexOf(destination);
476
+ if (pipeIdx !== -1) this._readableState.pipes.splice(pipeIdx, 1);
420
477
  destination.emit('unpipe', this);
421
478
  if (this._pipeDests.length === 0) {
422
479
  this.readableFlowing = false;
@@ -426,6 +483,14 @@ export class Readable extends Stream {
426
483
  return this;
427
484
  }
428
485
 
486
+ _destroy(error: Error | null, callback: (error?: Error | null) => void): void {
487
+ if (this._destroyImpl) {
488
+ this._destroyImpl.call(this, error, callback);
489
+ } else {
490
+ callback(error ?? undefined);
491
+ }
492
+ }
493
+
429
494
  destroy(error?: Error): this {
430
495
  if (this.destroyed) return this;
431
496
  this.destroyed = true;
@@ -439,7 +504,14 @@ export class Readable extends Stream {
439
504
  nextTick(() => this.emit('close'));
440
505
  };
441
506
 
442
- if (this._destroyImpl) {
507
+ // Dispatch virtually ONLY when the user overrode _destroy on the instance
508
+ // (e.g. tests: `stream._destroy = fn`). Do NOT call a subclass prototype
509
+ // _destroy: net.Socket's prototype `_destroy` synchronously cancels in-flight
510
+ // Gio I/O and would break tests that call destroy() during pending writes.
511
+ // The opts.destroy path still runs via _destroyImpl as before.
512
+ if (Object.prototype.hasOwnProperty.call(this, '_destroy')) {
513
+ (this as any)._destroy(error ?? null, cb);
514
+ } else if (this._destroyImpl) {
443
515
  this._destroyImpl.call(this, error ?? null, cb);
444
516
  } else {
445
517
  cb(error);
@@ -541,7 +613,21 @@ export class Readable extends Stream {
541
613
 
542
614
  // ---- Writable ----
543
615
 
544
- export class Writable extends Stream {
616
+ class Writable_ extends Stream_ {
617
+ // Allow `duplex instanceof Writable` to return true even though Duplex inherits
618
+ // from Readable_ only. Mirrors Node.js: standard prototype-chain check first,
619
+ // then duck-type fallback — but only when checking against Writable itself, not
620
+ // against a user subclass. Because `Writable` is exported as a makeCallable Proxy,
621
+ // `this === Writable_` fails for `obj instanceof Writable`; however the Proxy's
622
+ // default `get` trap forwards `.prototype` straight to the target, so the
623
+ // prototype reference check below correctly recognises both.
624
+ static [Symbol.hasInstance](obj: any): boolean {
625
+ if (typeof (this as any).prototype !== 'undefined' &&
626
+ Object.prototype.isPrototypeOf.call((this as any).prototype, obj)) return true;
627
+ if ((this as any).prototype !== Writable_.prototype) return false;
628
+ return obj !== null && obj !== undefined && typeof obj.writableHighWaterMark === 'number';
629
+ }
630
+
545
631
  writable = true;
546
632
  writableHighWaterMark: number;
547
633
  writableLength = 0;
@@ -649,7 +735,7 @@ export class Writable extends Stream {
649
735
  } else {
650
736
  nextTick(() => {
651
737
  callback();
652
- if (this.writableNeedDrain && this.writableLength < this.writableHighWaterMark) {
738
+ if (this.writableNeedDrain && this.writableLength <= this.writableHighWaterMark) {
653
739
  this.writableNeedDrain = false;
654
740
  this.emit('drain');
655
741
  }
@@ -817,7 +903,7 @@ export class Writable extends Stream {
817
903
  this.emit('error', err);
818
904
  } else {
819
905
  for (const b of buffered) b.callback();
820
- if (this.writableNeedDrain && this.writableLength < this.writableHighWaterMark) {
906
+ if (this.writableNeedDrain && this.writableLength <= this.writableHighWaterMark) {
821
907
  this.writableNeedDrain = false;
822
908
  this.emit('drain');
823
909
  }
@@ -861,7 +947,7 @@ export class Writable extends Stream {
861
947
 
862
948
  // ---- Duplex ----
863
949
 
864
- export class Duplex extends Readable {
950
+ class Duplex_ extends Readable_ {
865
951
  writable = true;
866
952
  writableHighWaterMark: number;
867
953
  writableLength = 0;
@@ -873,6 +959,9 @@ export class Duplex extends Readable {
873
959
  allowHalfOpen: boolean;
874
960
  private _decodeStrings: boolean;
875
961
 
962
+ // Exposed writable-side state (mirrors Node.js _writableState for split HWM tests)
963
+ _writableState = { highWaterMark: 0, objectMode: false };
964
+
876
965
  private _duplexCorkedBuffer: Array<{ chunk: any; encoding: string; callback: (error?: Error | null) => void }> = [];
877
966
  private _writeImpl: ((chunk: any, encoding: string, cb: (error?: Error | null) => void) => void) | undefined;
878
967
  private _finalImpl: ((cb: (error?: Error | null) => void) => void) | undefined;
@@ -882,8 +971,34 @@ export class Duplex extends Readable {
882
971
 
883
972
  constructor(opts?: DuplexOptions) {
884
973
  super(opts);
885
- this.writableHighWaterMark = opts?.writableHighWaterMark ?? opts?.highWaterMark ?? getDefaultHighWaterMark(opts?.writableObjectMode ?? opts?.objectMode ?? false);
974
+
975
+ validateHighWaterMark('writableHighWaterMark', opts?.writableHighWaterMark);
976
+ validateHighWaterMark('readableHighWaterMark', opts?.readableHighWaterMark);
977
+
978
+ // Writable side: highWaterMark (shared) takes priority over writableHighWaterMark.
886
979
  this.writableObjectMode = opts?.writableObjectMode ?? opts?.objectMode ?? false;
980
+ this.writableHighWaterMark = opts?.highWaterMark
981
+ ?? opts?.writableHighWaterMark
982
+ ?? getDefaultHighWaterMark(this.writableObjectMode);
983
+ this._writableState.highWaterMark = this.writableHighWaterMark;
984
+ this._writableState.objectMode = this.writableObjectMode;
985
+
986
+ // Readable side overrides: Readable_ constructor already applied opts.highWaterMark,
987
+ // so only override with readableHighWaterMark when highWaterMark was NOT set.
988
+ if (opts?.highWaterMark === undefined && opts?.readableHighWaterMark !== undefined) {
989
+ this.readableHighWaterMark = opts.readableHighWaterMark;
990
+ this._readableState.highWaterMark = opts.readableHighWaterMark;
991
+ }
992
+ if (opts?.readableObjectMode !== undefined) {
993
+ this.readableObjectMode = opts.readableObjectMode;
994
+ this._readableState.objectMode = opts.readableObjectMode;
995
+ // Re-derive readable HWM for objectMode when neither readableHighWaterMark nor highWaterMark was set.
996
+ if (opts?.readableHighWaterMark === undefined && opts?.highWaterMark === undefined) {
997
+ this.readableHighWaterMark = getDefaultHighWaterMark(opts.readableObjectMode);
998
+ this._readableState.highWaterMark = this.readableHighWaterMark;
999
+ }
1000
+ }
1001
+
887
1002
  this.allowHalfOpen = opts?.allowHalfOpen !== false;
888
1003
  this._decodeStrings = opts?.decodeStrings !== false;
889
1004
  if (opts?.write) this._writeImpl = opts.write;
@@ -982,7 +1097,7 @@ export class Duplex extends Readable {
982
1097
  } else {
983
1098
  nextTick(() => {
984
1099
  cb();
985
- if (this.writableNeedDrain && this.writableLength < this.writableHighWaterMark) {
1100
+ if (this.writableNeedDrain && this.writableLength <= this.writableHighWaterMark) {
986
1101
  this.writableNeedDrain = false;
987
1102
  this.emit('drain');
988
1103
  }
@@ -1018,11 +1133,15 @@ export class Duplex extends Readable {
1018
1133
  const doFinal = () => {
1019
1134
  this._final((err) => {
1020
1135
  this.writableFinished = true;
1021
- nextTick(() => {
1022
- if (err) this.emit('error', err);
1023
- this.emit('finish');
1024
- nextTick(() => this.emit('close'));
1025
- if (callback) callback();
1136
+ // Allow subclasses (Transform) to run post-final hooks (e.g. flush)
1137
+ // before the 'finish' event fires.
1138
+ this._doPrefinishHooks(() => {
1139
+ nextTick(() => {
1140
+ if (err) this.emit('error', err);
1141
+ this.emit('finish');
1142
+ nextTick(() => this.emit('close'));
1143
+ if (callback) callback();
1144
+ });
1026
1145
  });
1027
1146
  });
1028
1147
  };
@@ -1037,6 +1156,11 @@ export class Duplex extends Readable {
1037
1156
  return this;
1038
1157
  }
1039
1158
 
1159
+ /** Hook for subclasses to run logic between _final and 'finish'. Default: no-op. */
1160
+ protected _doPrefinishHooks(cb: () => void): void {
1161
+ cb();
1162
+ }
1163
+
1040
1164
  cork(): void { this.writableCorked++; }
1041
1165
 
1042
1166
  uncork(): void {
@@ -1055,7 +1179,7 @@ export class Duplex extends Readable {
1055
1179
  }
1056
1180
  });
1057
1181
  }
1058
- if (this.writableNeedDrain && this.writableLength < this.writableHighWaterMark) {
1182
+ if (this.writableNeedDrain && this.writableLength <= this.writableHighWaterMark) {
1059
1183
  this.writableNeedDrain = false;
1060
1184
  nextTick(() => this.emit('drain'));
1061
1185
  }
@@ -1071,48 +1195,62 @@ export class Duplex extends Readable {
1071
1195
 
1072
1196
  // ---- Transform ----
1073
1197
 
1074
- export class Transform extends Duplex {
1075
- private _transformImpl: ((chunk: any, encoding: string, cb: (error?: Error | null, data?: any) => void) => void) | undefined;
1076
- private _flushImpl: ((cb: (error?: Error | null, data?: any) => void) => void) | undefined;
1077
-
1198
+ class Transform_ extends Duplex_ {
1078
1199
  constructor(opts?: TransformOptions) {
1079
- super({
1080
- ...opts,
1081
- write: undefined, // Override write to use transform
1082
- });
1083
- if (opts?.transform) this._transformImpl = opts.transform;
1084
- if (opts?.flush) this._flushImpl = opts.flush;
1200
+ // Don't forward transform/flush/final/write — Transform's own method assignments
1201
+ // handle those. Passing write/final through would register them in Duplex_'s
1202
+ // _writeImpl/_finalImpl and bypass Transform's override.
1203
+ super({ ...opts, write: undefined, final: undefined });
1204
+ // Direct assignment mirrors Node.js: opts.transform/flush/final overwrite the
1205
+ // prototype methods on the instance so `t._transform === opts.transform` holds.
1206
+ if (opts?.transform) (this as any)._transform = opts.transform;
1207
+ if (opts?.flush) (this as any)._flush = opts.flush;
1208
+ if (opts?.final) (this as any)._final = opts.final;
1085
1209
  }
1086
1210
 
1087
- _transform(chunk: any, encoding: string, callback: (error?: Error | null, data?: any) => void): void {
1088
- if (this._transformImpl) {
1089
- this._transformImpl.call(this, chunk, encoding, callback);
1090
- } else {
1091
- callback(null, chunk);
1092
- }
1211
+ _transform(_chunk: any, _encoding: string, _callback: (error?: Error | null, data?: any) => void): void {
1212
+ // Throw when no implementation was provided (no opts.transform and no subclass override).
1213
+ const err = Object.assign(
1214
+ new Error('The _transform() method is not implemented'),
1215
+ { code: 'ERR_METHOD_NOT_IMPLEMENTED' }
1216
+ );
1217
+ throw err;
1093
1218
  }
1094
1219
 
1095
1220
  _flush(callback: (error?: Error | null, data?: any) => void): void {
1096
- if (this._flushImpl) {
1097
- this._flushImpl.call(this, callback);
1098
- } else {
1099
- callback();
1100
- }
1221
+ callback();
1101
1222
  }
1102
1223
 
1103
1224
  _write(chunk: any, encoding: string, callback: (error?: Error | null) => void): void {
1104
- this._transform(chunk, encoding, (err, data) => {
1105
- if (err) {
1106
- callback(err);
1107
- return;
1108
- }
1109
- if (data !== undefined && data !== null) {
1110
- this.push(data);
1111
- }
1112
- callback();
1113
- });
1225
+ let called = false;
1226
+ try {
1227
+ this._transform(chunk, encoding, (err, data) => {
1228
+ if (called) {
1229
+ const e = Object.assign(new Error('Callback called multiple times'), { code: 'ERR_MULTIPLE_CALLBACK' });
1230
+ nextTick(() => this.emit('error', e));
1231
+ return;
1232
+ }
1233
+ called = true;
1234
+ if (err) {
1235
+ callback(err);
1236
+ return;
1237
+ }
1238
+ if (data !== undefined && data !== null) {
1239
+ this.push(data);
1240
+ }
1241
+ callback();
1242
+ });
1243
+ } catch (err: any) {
1244
+ // ERR_METHOD_NOT_IMPLEMENTED must propagate synchronously (test-stream-transform-constructor-set-methods).
1245
+ // User-provided _transform errors are converted to 'error' events.
1246
+ if (err?.code === 'ERR_METHOD_NOT_IMPLEMENTED') throw err;
1247
+ callback(err as Error);
1248
+ }
1114
1249
  }
1115
1250
 
1251
+ // Transform's built-in _final: calls _flush then pushes null.
1252
+ // This is the default; when the user provides opts.final it is overridden on
1253
+ // the instance and _doPrefinishHooks ensures _flush is still called after it.
1116
1254
  _final(callback: (error?: Error | null) => void): void {
1117
1255
  this._flush((err, data) => {
1118
1256
  if (err) {
@@ -1127,11 +1265,24 @@ export class Transform extends Duplex {
1127
1265
  callback();
1128
1266
  });
1129
1267
  }
1268
+
1269
+ // When a user-provided _final overrides the prototype method, we still need
1270
+ // to call the built-in flush+push-null logic (mirroring Node.js's prefinish).
1271
+ protected override _doPrefinishHooks(cb: () => void): void {
1272
+ const protoFinal = Transform_.prototype._final;
1273
+ if ((this as any)._final !== protoFinal) {
1274
+ // User replaced _final; call the built-in flush+push-null now.
1275
+ protoFinal.call(this, cb);
1276
+ } else {
1277
+ // _final already ran flush+push-null; nothing extra needed.
1278
+ cb();
1279
+ }
1280
+ }
1130
1281
  }
1131
1282
 
1132
1283
  // ---- PassThrough ----
1133
1284
 
1134
- export class PassThrough extends Transform {
1285
+ class PassThrough_ extends Transform_ {
1135
1286
  constructor(opts?: TransformOptions) {
1136
1287
  super({
1137
1288
  ...opts,
@@ -1326,6 +1477,31 @@ export function isErrored(stream: unknown): boolean {
1326
1477
  }
1327
1478
 
1328
1479
  // ---- Exports ----
1480
+ //
1481
+ // The class declarations above use underscore-suffixed internal names
1482
+ // (`Stream_`, `Readable_`, etc.) — we wrap each one with `makeCallable` so
1483
+ // legacy CJS consumers can do `Stream.call(this)` (npm `send`,
1484
+ // `util.inherits(Sub, Stream)`, our own `@gjsify/crypto` `Hash.copy()`).
1485
+ // See `./callable.ts` for the rationale and implementation.
1486
+ //
1487
+ // Public API preserves the historical names. Both value and type positions
1488
+ // work because of the `type X = X_` aliases below.
1489
+
1490
+ import { makeCallable } from './callable.js';
1491
+
1492
+ export const Stream = makeCallable(Stream_) as typeof Stream_;
1493
+ export const Readable = makeCallable(Readable_) as typeof Readable_;
1494
+ export const Writable = makeCallable(Writable_) as typeof Writable_;
1495
+ export const Duplex = makeCallable(Duplex_) as typeof Duplex_;
1496
+ export const Transform = makeCallable(Transform_) as typeof Transform_;
1497
+ export const PassThrough = makeCallable(PassThrough_) as typeof PassThrough_;
1498
+
1499
+ export type Stream = Stream_;
1500
+ export type Readable = Readable_;
1501
+ export type Writable = Writable_;
1502
+ export type Duplex = Duplex_;
1503
+ export type Transform = Transform_;
1504
+ export type PassThrough = PassThrough_;
1329
1505
 
1330
1506
  // Default export
1331
1507
  const _default = Object.assign(Stream, {