through2
Version:
Tiny utilities for inserting transformation logic into Node.js stream and Web Streams pipelines
405 lines (368 loc) • 11.8 kB
JavaScript
import { Readable } from 'readable-stream'
import through2, {
transform,
objectTransform,
transformer
} from '../through2.js'
const decoder = new TextDecoder('ascii')
const encoder = new TextEncoder()
const eq = (actual, expected, msg) => {
if (actual !== expected) {
throw new Error(`${msg || 'eq'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
}
}
const deepEq = (actual, expected, msg) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`${msg || 'deepEq'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`)
}
}
const ok = (val, msg) => {
if (!val) throw new Error(msg || 'expected truthy')
}
function randomBytes (len) {
const bytes = new Uint8Array(len)
globalThis.crypto.getRandomValues(bytes)
return bytes
}
function collect (stream) {
return new Promise((resolve, reject) => {
/** @type {Uint8Array[]} */
const chunks = []
stream.on('data', (chunk) => chunks.push(chunk))
stream.on('end', () => {
let total = 0
for (const c of chunks) total += c.length
const out = new Uint8Array(total)
let i = 0
for (const c of chunks) { out.set(c, i); i += c.length }
resolve(out)
})
stream.on('error', reject)
})
}
function collectObjects (stream) {
return new Promise((resolve, reject) => {
const objs = []
stream.on('data', (o) => objs.push(o))
stream.on('end', () => resolve(objs))
stream.on('error', reject)
})
}
export async function classicByteTransform () {
const stream = transform(function (chunk, _enc, cb) {
if (!this._i) this._i = 97
else this._i++
const out = new Uint8Array(chunk.length)
out.fill(this._i)
this.push(out)
cb()
})
const done = collect(stream)
stream.write(randomBytes(10))
stream.write(randomBytes(5))
stream.write(randomBytes(10))
stream.end()
eq(decoder.decode(await done), 'aaaaaaaaaabbbbbcccccccccc')
}
export async function classicNoopTransform () {
const stream = transform()
const done = collect(stream)
stream.end(encoder.encode('eeee'))
eq(decoder.decode(await done), 'eeee')
}
export async function classicFlush () {
const stream = transform(
function (chunk, _enc, cb) {
if (!this._i) this._i = 97
else this._i++
const out = new Uint8Array(chunk.length)
out.fill(this._i)
this.push(out)
cb()
},
function (cb) {
this.push(encoder.encode('end'))
cb()
}
)
const done = collect(stream)
stream.write(randomBytes(10))
stream.write(randomBytes(5))
stream.write(randomBytes(10))
stream.end()
eq(decoder.decode(await done), 'aaaaaaaaaabbbbbccccccccccend')
}
export async function classicCallbackShorthand () {
const stream = transform((chunk, _enc, cb) => cb(null, chunk))
const done = collect(stream)
stream.end(encoder.encode('hello'))
eq(decoder.decode(await done), 'hello')
}
export async function asyncReturnedValuePushed () {
const stream = transform(async (_chunk, _enc) => encoder.encode('X'))
const done = collect(stream)
stream.write(new Uint8Array([1]))
stream.write(new Uint8Array([2]))
stream.end()
eq(decoder.decode(await done), 'XX')
}
export async function asyncUndefinedSkipsEmission () {
const stream = objectTransform(async (chunk) => chunk % 2 === 0 ? chunk : undefined)
const done = collectObjects(stream)
for (let i = 0; i < 6; i++) stream.write(i)
stream.end()
deepEq(await done, [0, 2, 4])
}
export async function asyncRejectedPropagatesError () {
const stream = transform(async () => { throw new Error('boom') })
stream.write(new Uint8Array([1]))
await new Promise((resolve, reject) => {
stream.on('error', (err) => {
try { eq(err.message, 'boom'); resolve() } catch (e) { reject(e) }
})
})
}
export async function asyncgenOneToOne () {
const stream = objectTransform(async function * (source) {
for await (const chunk of source) yield chunk * 2
})
const done = collectObjects(stream)
for (let i = 1; i <= 4; i++) stream.write(i)
stream.end()
deepEq(await done, [2, 4, 6, 8])
}
export async function asyncgenFanOut () {
const stream = objectTransform(async function * (source) {
for await (const chunk of source) {
yield chunk
yield chunk
}
})
const done = collectObjects(stream)
stream.write('a')
stream.write('b')
stream.end()
deepEq(await done, ['a', 'a', 'b', 'b'])
}
export async function asyncgenFilter () {
const stream = objectTransform(async function * (source) {
for await (const chunk of source) {
if (chunk % 2 === 0) yield chunk
}
})
const done = collectObjects(stream)
for (let i = 0; i < 6; i++) stream.write(i)
stream.end()
deepEq(await done, [0, 2, 4])
}
export async function asyncgenWithClassicFlush () {
const stream = objectTransform(
async function * (source) {
for await (const chunk of source) yield chunk
},
function (cb) { this.push('END'); cb() }
)
const done = collectObjects(stream)
stream.write('a')
stream.write('b')
stream.end()
deepEq(await done, ['a', 'b', 'END'])
}
export async function asyncgenThrownPropagates () {
const stream = objectTransform(async function * () {
throw new Error('gen-boom')
})
stream.write('x')
stream.end()
await new Promise((resolve, reject) => {
stream.on('error', (err) => {
try { eq(err.message, 'gen-boom'); resolve() } catch (e) { reject(e) }
})
})
}
export async function objectTransformPassthrough () {
const stream = objectTransform(function (chunk, _enc, cb) {
this.push({ out: chunk.in + 1 })
cb()
})
const done = collectObjects(stream)
stream.write({ in: 1 })
stream.write({ in: 2 })
stream.write({ in: 3 })
stream.end()
deepEq(await done, [{ out: 2 }, { out: 3 }, { out: 4 }])
}
export async function objectTransformFromReadable () {
const source = Readable.from([
{ temp: -2.2, unit: 'F' },
{ temp: -40, unit: 'F' },
{ temp: 212, unit: 'F' },
{ temp: 22, unit: 'C' }
])
const conv = objectTransform(async (record) => {
if (record.unit === 'F') {
record = { temp: ((record.temp - 32) * 5) / 9, unit: 'C' }
}
return record
})
source.pipe(conv)
const out = await collectObjects(conv)
deepEq(out.map((r) => Math.round(r.temp)), [-19, -40, 100, 22])
ok(out.every((r) => r.unit === 'C'))
}
export async function transformerReusableFactory () {
const Th = transformer(function (chunk, _enc, cb) {
if (!this._i) this._i = 97
else this._i++
const out = new Uint8Array(chunk.length)
out.fill(this._i)
this.push(out)
cb()
})
const a = new Th()
const b = Th()
const da = collect(a); const db = collect(b)
a.write(randomBytes(3)); a.write(randomBytes(2)); a.end()
b.write(randomBytes(2)); b.write(randomBytes(3)); b.end()
eq(decoder.decode(await da), 'aaabb')
eq(decoder.decode(await db), 'aabbb')
}
export async function transformerOptionsMerge () {
const Th = transformer({ objectMode: true, peek: true }, function (chunk, _enc, cb) {
ok(this.options.peek, 'options visible inside transform')
this.push({ out: chunk.in + 1 })
cb()
})
const inst = Th()
const done = collectObjects(inst)
inst.write({ in: 1 })
inst.write({ in: 2 })
inst.end()
deepEq(await done, [{ out: 2 }, { out: 3 }])
}
export async function transformerOverrideOptions () {
const Th = transformer(function (chunk, _enc, cb) {
this.push({ out: chunk.in + 1 })
cb()
})
const inst = Th({ objectMode: true })
const done = collectObjects(inst)
inst.write({ in: 10 })
inst.end()
deepEq(await done, [{ out: 11 }])
}
export async function transformerAsyncSupported () {
const Th = transformer({ objectMode: true }, async (chunk) => chunk * 10)
const inst = Th()
const done = collectObjects(inst)
inst.write(1); inst.write(2); inst.end()
deepEq(await done, [10, 20])
}
export async function legacyDefault () {
const stream = through2((chunk, _enc, cb) => cb(null, chunk))
const done = collect(stream)
stream.end(encoder.encode('legacy'))
eq(decoder.decode(await done), 'legacy')
}
export async function legacyDefaultObj () {
const stream = through2.obj(function (chunk, _enc, cb) {
this.push({ out: chunk.in + 1 })
cb()
})
const done = collectObjects(stream)
stream.write({ in: 1 })
stream.end()
deepEq(await done, [{ out: 2 }])
}
export async function legacyDefaultCtor () {
const Th = through2.ctor({ objectMode: true }, function (chunk, _enc, cb) {
this.push({ out: chunk.in + 1 })
cb()
})
const inst = new Th()
const done = collectObjects(inst)
inst.write({ in: 5 })
inst.end()
deepEq(await done, [{ out: 6 }])
}
export async function transformerAsyncgenSupported () {
// Each call gets its own generator pipeline.
const Doubled = transformer({ objectMode: true }, async function * (source) {
for await (const x of source) yield x * 2
})
const a = Doubled()
const b = Doubled()
const da = collectObjects(a); const db = collectObjects(b)
a.write(1); a.write(2); a.end()
b.write(10); b.write(20); b.end()
deepEq(await da, [2, 4])
deepEq(await db, [20, 40])
}
// Regression: with low readable HWM, async-gen fan-out used to deadlock
// awaiting `drain` (a writable event that never fires here).
export async function asyncgenDownstreamBackpressure () {
const t = objectTransform({ highWaterMark: 1 }, async function * (source) {
// eslint-disable-next-line no-unused-vars
for await (const _ of source) for (let i = 0; i < 20; i++) yield i
})
t.write('start'); t.end()
await new Promise((resolve) => setTimeout(resolve, 50)) // delay consumption
const out = await collectObjects(t)
deepEq(out, Array.from({ length: 20 }, (_, i) => i))
}
// Slow generator + many fast writes must propagate backpressure upstream
// (drain awaits) instead of growing an internal queue without bound.
export async function asyncgenWritableBackpressure () {
const t = objectTransform({ highWaterMark: 4 }, async function * (source) {
for await (const x of source) {
await new Promise((resolve) => setTimeout(resolve, 5))
yield x
}
})
let drainsAwaited = 0
const writePromise = (async () => {
for (let i = 0; i < 20; i++) {
if (!t.write(i)) {
drainsAwaited++
await new Promise((resolve) => t.once('drain', resolve))
}
}
t.end()
})()
const out = await collectObjects(t)
await writePromise
deepEq(out, Array.from({ length: 20 }, (_, i) => i))
ok(drainsAwaited > 0, 'expected drain awaits, got 0')
}
// Mid-stream destroy must release pending awaits so the consumer loop exits.
export async function asyncgenDestroyMidStream () {
const t = objectTransform(async function * (source) {
for await (const x of source) { yield x; yield x }
})
const seen = []
t.on('data', (d) => {
seen.push(d)
if (seen.length === 2) t.destroy()
})
t.write(1); t.write(2); t.write(3)
await new Promise((resolve) => t.once('close', resolve))
ok(seen.length >= 2)
}
// Destroy must finalize the user's async generator: its `finally` block
// must run so resources (file handles, subscriptions, etc.) can be released.
export async function asyncgenDestroyRunsFinally () {
let finalized = false
const t = objectTransform(async function * (source) {
try {
for await (const x of source) yield x
} finally {
finalized = true
}
})
t.write('a')
await new Promise((resolve) => setTimeout(resolve, 20))
t.destroy()
await new Promise((resolve) => t.once('close', resolve))
await new Promise((resolve) => setTimeout(resolve, 20)) // let finally settle
ok(finalized, 'generator finally must run on destroy')
}