current commit
261010.25 · stream: accept explicit stream/iter budgets below 16384
// Flags: --experimental-stream-iter
'use strict';

const common = require('../common');
const assert = require('assert');
const { Writable } = require('stream');
const {
  from, fromSync, pull, pullSync, pipeTo, fromReadable, fromWritable,
  push, duplex, broadcast, Broadcast, share, shareSync,
  Share, SyncShare,
  bytes, bytesSync, text, textSync,
  arrayBuffer, arrayBufferSync, array, arraySync,
  tap, tapSync,
} = require('stream/iter');
const {
  compressGzip, compressBrotli, compressZstd,
  decompressGzip, decompressBrotli, decompressZstd,
} = require('zlib/iter');

// =============================================================================
// push() validation
// =============================================================================

// Budget must be an integer >= 1
assert.throws(() => push({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
// WebIDL conversion truncates fractions: 0.5 becomes 0, 1.5 becomes 1.
assert.throws(() => push({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.strictEqual(push({ budget: 1.5 }).writer.canWrite, true);
// Values < 1 are rejected
assert.throws(() => push({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => push({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => push({ budget: -100 }), { code: 'ERR_OUT_OF_RANGE' });
// Only the default must be at least 16384; smaller explicit budgets are valid
assert.strictEqual(push({ budget: 1 }).writer.canWrite, true);
assert.strictEqual(push({ budget: 16383 }).writer.canWrite, true);
assert.strictEqual(push({ budget: 16384 }).writer.canWrite, true);
// MAX_SAFE_INTEGER is accepted
assert.strictEqual(push({ budget: Number.MAX_SAFE_INTEGER }).writer.canWrite,
                   true);
// Values above MAX_SAFE_INTEGER are rejected by validateInteger
assert.throws(() => push({ budget: Number.MAX_SAFE_INTEGER + 1 }),
              { code: 'ERR_OUT_OF_RANGE' });

// Signal must be AbortSignal
assert.throws(() => push({ signal: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => push({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });

// Transforms must be functions or transform objects
assert.throws(() => push(42, {}), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => push('bad', {}), { code: 'ERR_INVALID_ARG_TYPE' });

// Writer options.signal must be AbortSignal
{
  const { writer } = push();
  const badOptions = { signal: 'bad' };
  assert.throws(() => writer.write('a', badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writev(['b'], badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.end(badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  writer.endSync();
}

// Writer.writev requires array
{
  const { writer } = push();
  assert.throws(() => writer.writev('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writev(42), { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writevSync('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
  writer.endSync();
}

// Writer chunks use the Web IDL (Uint8Array or USVString) conversion.
{
  const { writer } = push();
  assert.throws(() => writer.writeSync(Symbol()),
                { code: 'ERR_INVALID_ARG_TYPE' });
  writer.endSync();
}

// =============================================================================
// duplex() validation
// =============================================================================

assert.throws(() => duplex(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => duplex('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => duplex({ a: 42 }), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => duplex({ b: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });

// Budget validation (cascades through to push())
assert.throws(() => duplex({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => duplex({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => duplex({ budget: Number.MAX_SAFE_INTEGER + 1 }),
              { code: 'ERR_OUT_OF_RANGE' });

// Values < 1 are rejected (both directions)
assert.throws(() => duplex({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
// MAX_SAFE_INTEGER is accepted
{
  const [a, b] = duplex({ budget: Number.MAX_SAFE_INTEGER });
  assert.strictEqual(a.writer.canWrite, true);
  assert.strictEqual(b.writer.canWrite, true);
  a.writer.endSync();
  b.writer.endSync();
}
// Per-direction overrides
{
  const [a, b] = duplex({ a: { budget: 16384 }, b: { budget: 32768 } });
  assert.strictEqual(a.writer.canWrite, true);
  assert.strictEqual(b.writer.canWrite, true);
  a.writer.endSync();
  b.writer.endSync();
}

assert.throws(() => duplex({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });

// =============================================================================
// pull() / pullSync() validation
// =============================================================================

// Signal must be AbortSignal
assert.throws(() => pull(from('a'), { signal: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });

// Transforms must be functions or transform objects
assert.throws(() => pull(from('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => pull(from('a'), 'bad'), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => pullSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });

// =============================================================================
// broadcast() validation
// =============================================================================

assert.throws(() => broadcast({ budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => broadcast({ budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => broadcast({ budget: Number.MAX_SAFE_INTEGER + 1 }),
              { code: 'ERR_OUT_OF_RANGE' });

// Values < 1 are rejected
assert.throws(() => broadcast({ budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => broadcast({ budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
// Small explicit budgets are accepted
{
  const bc = broadcast({ budget: 1 });
  bc.broadcast.push();
  assert.strictEqual(bc.writer.canWrite, true);
  bc.writer.endSync();
}
// MAX_SAFE_INTEGER is accepted
{
  const bc = broadcast({ budget: Number.MAX_SAFE_INTEGER });
  bc.broadcast.push();
  assert.strictEqual(bc.writer.canWrite, true);
  bc.writer.endSync();
}

assert.throws(() => broadcast({ signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => broadcast({ backpressure: 'bad' }), { code: 'ERR_INVALID_ARG_VALUE' });

// Broadcast consumer options.signal must be AbortSignal and validation must
// not leave a consumer registered.
{
  const { broadcast: bc } = broadcast();
  assert.throws(() => bc.push({ signal: {} }),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.strictEqual(bc.consumerCount, 0);
}

// BroadcastWriter options.signal must be AbortSignal
{
  const { writer } = broadcast();
  const badOptions = { signal: 'bad' };
  assert.throws(() => writer.write('a', badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writev(['b'], badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.end(badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  writer.endSync();
}

// BroadcastWriter.writev requires array
{
  const { writer } = broadcast();
  assert.throws(() => writer.writev('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writev(42), { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writevSync('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writevSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
  writer.endSync();
}

// Broadcast.from rejects non-streamable input
assert.throws(() => Broadcast.from(42), { code: 'ERR_INVALID_ARG_TYPE' });

assert.throws(
  () => fromWritable({ write() {}, on() {} }),
  { code: 'ERR_INVALID_ARG_TYPE' },
);
assert.throws(
  () => fromReadable({ read() {}, on() {} }),
  { code: 'ERR_INVALID_ARG_TYPE' },
);

// Duck-typed readables must provide each of the required methods.
{
  const required = ['read', 'pipe', 'destroy', 'on', 'removeListener'];
  for (const missing of required) {
    const duck = {};
    for (const name of required) {
      if (name !== missing) duck[name] = () => {};
    }
    assert.throws(() => fromReadable(duck), { code: 'ERR_INVALID_ARG_TYPE' });
  }
}

// fromWritable Writer options.signal must be AbortSignal
{
  const writable = new Writable({
    write(chunk, encoding, callback) {
      callback();
    },
  });
  const writer = fromWritable(writable);
  const badOptions = { signal: 'bad' };
  assert.throws(() => writer.write('a', badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.writev(['b'], badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.throws(() => writer.end(badOptions),
                { code: 'ERR_INVALID_ARG_TYPE' });
  writable.destroy();
}

// =============================================================================
// share() / shareSync() validation
// =============================================================================

assert.throws(() => share(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => share(from('a'), { budget: 'bad' }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { budget: 0.5 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }),
              { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { signal: {} }), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => share(from('a'), { backpressure: 'bad' }), { code: 'ERR_INVALID_ARG_VALUE' });

// Share consumer options.signal must be AbortSignal and validation must not
// leave a consumer registered.
{
  const shared = share(from('a'));
  assert.throws(() => shared.pull({ signal: {} }),
                { code: 'ERR_INVALID_ARG_TYPE' });
  assert.strictEqual(shared.consumerCount, 0);
}

// share() values < 1 are rejected
assert.throws(() => share(from('a'), { budget: 0 }), { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => share(from('a'), { budget: -1 }), { code: 'ERR_OUT_OF_RANGE' });
// 1 is the minimum, MAX_SAFE_INTEGER is accepted
share(from('a'), { budget: 1 }).cancel();
share(from('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel();

assert.throws(() => shareSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => shareSync(fromSync('a'), { budget: 'bad' }),
              { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => shareSync(fromSync('a'), { budget: 0.5 }),
              { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER + 1 }),
              { code: 'ERR_OUT_OF_RANGE' });

// shareSync() values < 1 are rejected
assert.throws(() => shareSync(fromSync('a'), { budget: 0 }),
              { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => shareSync(fromSync('a'), { budget: -1 }),
              { code: 'ERR_OUT_OF_RANGE' });
// 1 is the minimum, MAX_SAFE_INTEGER is accepted
shareSync(fromSync('a'), { budget: 1 }).cancel();
shareSync(fromSync('a'), { budget: Number.MAX_SAFE_INTEGER }).cancel();

// Share.from / SyncShare.fromSync reject non-iterable
assert.throws(() => Share.from(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => SyncShare.fromSync(42), { code: 'ERR_INVALID_ARG_TYPE' });

// =============================================================================
// Consumer validation (synchronous)
// =============================================================================

// tap / tapSync require function
assert.throws(() => tap(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => tap('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => tapSync(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => tapSync(null), { code: 'ERR_INVALID_ARG_TYPE' });

// Sync consumer options
assert.throws(() => bytesSync(fromSync('a'), { limit: 'bad' }),
              { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => bytesSync(fromSync('a'), { limit: -1 }),
              { code: 'ERR_OUT_OF_RANGE' });
assert.throws(() => textSync(fromSync('a'), { encoding: 42 }),
              { code: 'ERR_INVALID_ARG_VALUE' });
assert.throws(() => textSync(fromSync('a'), { encoding: 'bogus' }),
              { code: 'ERR_INVALID_ARG_VALUE' });
assert.throws(() => arrayBufferSync(fromSync('a'), { limit: 'bad' }),
              { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => arraySync(fromSync('a'), { limit: -1 }),
              { code: 'ERR_OUT_OF_RANGE' });

// Options must be object if provided
assert.throws(() => bytesSync(fromSync('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => textSync(fromSync('a'), 'bad'), { code: 'ERR_INVALID_ARG_TYPE' });

// Compression options must be object
assert.throws(() => compressGzip(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => decompressGzip('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => compressBrotli(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => decompressBrotli('bad'), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => compressZstd(42), { code: 'ERR_INVALID_ARG_TYPE' });
assert.throws(() => decompressZstd('bad'), { code: 'ERR_INVALID_ARG_TYPE' });

// =============================================================================
// Async consumer and compression validation
// =============================================================================

// Helper: consume a transform through a pipeline to trigger lazy validation.
const consume = (transform) => bytes(pull(from('test'), transform));

async function testAsyncValidation() {
  // pipeTo signal
  await assert.rejects(
    () => pipeTo(from('a'), { write() {} }, { signal: 'bad' }),
    { code: 'ERR_INVALID_ARG_TYPE' },
  );

  // Async consumer options
  await assert.rejects(
    () => bytes(from('a'), 42), { code: 'ERR_INVALID_ARG_TYPE' });
  await assert.rejects(
    () => bytes(from('a'), { signal: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
  await assert.rejects(
    () => bytes(from('a'), { limit: 'bad' }), { code: 'ERR_INVALID_ARG_TYPE' });
  await assert.rejects(
    () => bytes(from('a'), { limit: -1 }), { code: 'ERR_OUT_OF_RANGE' });
  await assert.rejects(
    () => text(from('a'), { encoding: 42 }), { code: 'ERR_INVALID_ARG_VALUE' });
  await assert.rejects(
    () => text(from('a'), { encoding: 'not-a-real-encoding' }),
    { code: 'ERR_INVALID_ARG_VALUE' });
  await assert.rejects(
    () => arrayBuffer(from('a'), { limit: 'bad' }),
    { code: 'ERR_INVALID_ARG_TYPE' });
  await assert.rejects(
    () => array(from('a'), { limit: -1 }), { code: 'ERR_OUT_OF_RANGE' });

  const TYPE = { code: 'ERR_INVALID_ARG_TYPE' };
  const RANGE = { code: 'ERR_OUT_OF_RANGE' };
  const BROTLI = { code: 'ERR_BROTLI_INVALID_PARAM' };
  const ZSTD = { code: 'ERR_ZSTD_INVALID_PARAM' };

  // ChunkSize
  await assert.rejects(consume(compressGzip({ chunkSize: 'bad' })), TYPE);
  await assert.rejects(consume(compressGzip({ chunkSize: 0 })), RANGE);
  await assert.rejects(consume(compressGzip({ chunkSize: 10 })), RANGE);

  // WindowBits
  await assert.rejects(consume(compressGzip({ windowBits: 'bad' })), TYPE);
  await assert.rejects(consume(compressGzip({ windowBits: 100 })), RANGE);

  // Level
  await assert.rejects(consume(compressGzip({ level: 'bad' })), TYPE);
  await assert.rejects(consume(compressGzip({ level: 100 })), RANGE);

  // MemLevel
  await assert.rejects(consume(compressGzip({ memLevel: 'bad' })), TYPE);
  await assert.rejects(consume(compressGzip({ memLevel: 100 })), RANGE);

  // Strategy
  await assert.rejects(consume(compressGzip({ strategy: 'bad' })), TYPE);
  await assert.rejects(consume(compressGzip({ strategy: 100 })), RANGE);

  // Dictionary
  await assert.rejects(consume(compressGzip({ dictionary: 42 })), TYPE);
  await assert.rejects(consume(compressGzip({ dictionary: 'bad' })), TYPE);

  // Brotli params
  await assert.rejects(consume(compressBrotli({ params: 42 })), TYPE);
  await assert.rejects(consume(compressBrotli({ params: { bad: 1 } })), BROTLI);
  await assert.rejects(consume(compressBrotli({ params: { [-1]: 1 } })), BROTLI);
  await assert.rejects(consume(compressBrotli({ params: { 0: 'bad' } })), TYPE);

  // Zstd params
  await assert.rejects(consume(compressZstd({ params: 42 })), TYPE);
  await assert.rejects(consume(compressZstd({ params: { bad: 1 } })), ZSTD);
  await assert.rejects(consume(compressZstd({ params: { 0: 'bad' } })), TYPE);

  // Zstd pledgedSrcSize
  await assert.rejects(consume(compressZstd({ pledgedSrcSize: 'bad' })), TYPE);
  for (const pledgedSrcSize of [
    NaN,
    Infinity,
    -Infinity,
    1.9,
    -1,
    Number.MAX_SAFE_INTEGER + 1,
  ]) {
    await assert.rejects(consume(compressZstd({ pledgedSrcSize })), RANGE);
  }
}

// =============================================================================
// Valid calls still work
// =============================================================================

// Push with valid options
{
  const { writer } = push({ budget: 16384 });
  writer.writeSync('hello');
  writer.endSync();
}

// Duplex with valid options
{
  const [a, b] = duplex({ budget: 16384 });
  a.writer.endSync();
  b.writer.endSync();
}

// Broadcast with valid options
{
  const { writer } = broadcast({ budget: 16384 });
  writer.endSync();
}

// Share with valid options
{
  const shared = share(from('hello'), { budget: 16384 });
  shared.cancel();
}

// Compression with valid options
{
  const transform = compressGzip({ chunkSize: 1024, level: 6 });
  assert.strictEqual(typeof transform.transform, 'function');
}

// Brotli with valid params
{
  const { constants: { BROTLI_PARAM_QUALITY } } = require('zlib');
  const transform = compressBrotli({ params: { [BROTLI_PARAM_QUALITY]: 5 } });
  assert.strictEqual(typeof transform.transform, 'function');
}

testAsyncValidation().then(common.mustCall());