Skip to content

Commit c82c2f3

Browse files
committed
fix: correctly peek only first maxBuffer bytes in createPeekStream
Refactor createPeekStream into createLazyTransform: - Use Transform instead of Duplex for a simpler, idiomatic interface - Synchronous `choose(data)` replaces callback-based `swap(err, stream)` - Buffer chunks in an array, concat only once when threshold is reached - Default maxBuffer to 10 (the only value used in practice) - Rename to createLazyTransform to better reflect its purpose
1 parent 0ae21a5 commit c82c2f3

3 files changed

Lines changed: 192 additions & 62 deletions

File tree

index.js

Lines changed: 11 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
const zlib = require('node:zlib')
44
const { inherits, format } = require('node:util')
5-
const { pipeline, compose, Duplex } = require('node:stream')
5+
const { pipeline, compose } = require('node:stream')
66

77
const fp = require('fastify-plugin')
88
const encodingNegotiator = require('@fastify/accept-negotiator')
99
const mimedb = require('mime-db')
1010
const { Minipass } = require('minipass')
1111
const { Readable } = require('readable-stream')
1212

13-
const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable } = require('./lib/utils')
13+
const { isStream, isGzip, isDeflate, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable, createLazyTransform } = require('./lib/utils')
1414

1515
const InvalidRequestEncodingError = createError('FST_CP_ERR_INVALID_CONTENT_ENCODING', 'Unsupported Content-Encoding: %s', 415)
1616
const InvalidRequestCompressedPayloadError = createError('FST_CP_ERR_INVALID_CONTENT', 'Could not decompress the request payload using the provided encoding', 400)
@@ -555,75 +555,27 @@ function maybeUnzip (payload, serialize) {
555555
return Readable.from(intoAsyncIterator(result))
556556
}
557557

558-
function createPeekStream (maxBuffer, onpeek) {
559-
let buf = Buffer.alloc(0)
560-
let dest = null
561-
562-
return new Duplex({
563-
write (chunk, encoding, cb) {
564-
if (dest) {
565-
return dest.write(chunk, encoding, cb)
566-
}
567-
568-
buf = Buffer.concat([buf, chunk])
569-
570-
if (buf.length < maxBuffer) return cb()
571-
572-
onpeek(buf, (err, stream) => {
573-
if (err) return cb(err)
574-
dest = stream
575-
dest.on('data', (d) => this.push(d))
576-
dest.on('end', () => this.push(null))
577-
dest.write(buf, cb)
578-
buf = null
579-
})
580-
},
581-
582-
final (cb) {
583-
if (dest) {
584-
dest.end(cb)
585-
return
586-
}
587-
588-
onpeek(buf, (err, stream) => {
589-
if (err) return cb(err)
590-
dest = stream
591-
dest.on('data', (d) => this.push(d))
592-
dest.on('end', () => { this.push(null); cb() })
593-
if (buf && buf.length > 0) {
594-
dest.end(buf)
595-
} else {
596-
dest.end()
597-
}
598-
buf = null
599-
})
600-
},
601-
602-
read () {}
603-
})
604-
}
605-
606558
function zipStream (deflate, encoding) {
607-
return createPeekStream(10, function (data, swap) {
559+
return createLazyTransform(function (data) {
608560
switch (isCompressed(data)) {
609-
case 1: return swap(null, new Minipass())
610-
case 2: return swap(null, new Minipass())
561+
case 1: return new Minipass()
562+
case 2: return new Minipass()
611563
}
612-
return swap(null, deflate[encoding]())
564+
return deflate[encoding]()
613565
})
614566
}
615567

616568
function unzipStream (inflate, maxRecursion) {
617569
if (!(maxRecursion >= 0)) maxRecursion = 3
618-
return createPeekStream(10, function (data, swap) {
570+
return createLazyTransform(function (data) {
619571
// This path is never taken, when `maxRecursion` < 0 it is automatically set back to 3
620572
/* c8 ignore next */
621-
if (maxRecursion < 0) return swap(new Error('Maximum recursion reached'))
573+
if (maxRecursion < 0) throw new Error('Maximum recursion reached')
622574
switch (isCompressed(data)) {
623-
case 1: return swap(null, compose(inflate.gzip(), unzipStream(inflate, maxRecursion - 1)))
624-
case 2: return swap(null, compose(inflate.deflate(), unzipStream(inflate, maxRecursion - 1)))
575+
case 1: return compose(inflate.gzip(), unzipStream(inflate, maxRecursion - 1))
576+
case 2: return compose(inflate.deflate(), unzipStream(inflate, maxRecursion - 1))
625577
}
626-
return swap(null, new Minipass())
578+
return new Minipass()
627579
})
628580
}
629581

lib/utils.js

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use strict'
22

3-
const { Readable: NodeReadable } = require('node:stream')
3+
const { Readable: NodeReadable, Transform } = require('node:stream')
44

55
// https://datatracker.ietf.org/doc/html/rfc8878#section-3.1.1
66
function isZstd (buffer) {
@@ -104,4 +104,61 @@ async function * intoAsyncIterator (payload) {
104104
yield payload
105105
}
106106

107-
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable }
107+
/**
108+
* Creates a Transform that lazily selects an inner transform by peeking
109+
* at the first `maxBuffer` bytes.
110+
*
111+
* @param {(data: Buffer) => import('stream').Transform} choose
112+
* Called once with the peeked bytes. Must return the transform stream
113+
* that all data (including the peeked bytes) will be piped through.
114+
* @param {number} [maxBuffer=10] - Number of bytes to peek before deciding.
115+
* @returns {import('stream').Transform}
116+
*/
117+
function createLazyTransform (choose, maxBuffer = 10) {
118+
let chunks = []
119+
let chunksLength = 0
120+
let dest = null
121+
122+
return new Transform({
123+
transform (chunk, encoding, cb) {
124+
if (dest) {
125+
return dest.write(chunk, encoding, cb)
126+
}
127+
128+
chunks.push(chunk)
129+
chunksLength += chunk.length
130+
131+
if (chunksLength < maxBuffer) return cb()
132+
133+
const buf = Buffer.concat(chunks)
134+
chunks = null
135+
136+
dest = choose(buf.subarray(0, maxBuffer))
137+
dest.on('data', (d) => this.push(d))
138+
dest.on('end', () => this.push(null))
139+
dest.write(buf, cb)
140+
},
141+
142+
flush (cb) {
143+
if (!dest) {
144+
const buf = Buffer.concat(chunks)
145+
chunks = null
146+
147+
dest = choose(buf)
148+
dest.on('data', (d) => this.push(d))
149+
dest.on('end', () => { this.push(null); cb() })
150+
if (buf.length > 0) {
151+
dest.end(buf)
152+
} else {
153+
dest.end()
154+
}
155+
return
156+
}
157+
158+
dest.on('end', cb)
159+
dest.end()
160+
}
161+
})
162+
}
163+
164+
module.exports = { isZstd, isGzip, isDeflate, isStream, intoAsyncIterator, isWebReadableStream, isFetchResponse, webStreamToNodeReadable, createLazyTransform }

test/utils.test.js

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
const { createReadStream } = require('node:fs')
44
const { Socket } = require('node:net')
55
const { Duplex, PassThrough, Readable, Stream, Transform, Writable } = require('node:stream')
6+
const { pipeline } = require('node:stream/promises')
67
const { test } = require('node:test')
7-
const { isStream, isZstd, isDeflate, isGzip, intoAsyncIterator } = require('../lib/utils')
8+
const { isStream, isZstd, isDeflate, isGzip, intoAsyncIterator, createLazyTransform } = require('../lib/utils')
89

910
test('isStream() utility should be able to detect Streams', async (t) => {
1011
t.plan(12)
@@ -124,3 +125,123 @@ test('intoAsyncIterator() utility should handle different data', async (t) => {
124125
equal(chunk, obj)
125126
}
126127
})
128+
129+
test('createLazyTransform() should peek at first N bytes and pipe through selected stream', async (t) => {
130+
const input = Buffer.from('hello world')
131+
const chunks = []
132+
133+
const peekStream = createLazyTransform((data) => {
134+
t.assert.equal(data.length, 5)
135+
t.assert.equal(data.toString(), 'hello')
136+
return new PassThrough()
137+
}, 5)
138+
139+
peekStream.on('data', (chunk) => chunks.push(chunk))
140+
141+
await new Promise((resolve, reject) => {
142+
peekStream.on('end', resolve)
143+
peekStream.on('error', reject)
144+
peekStream.write(input)
145+
peekStream.end()
146+
})
147+
148+
t.assert.equal(Buffer.concat(chunks).toString(), 'hello world')
149+
})
150+
151+
test('createLazyTransform() should work when data arrives in small chunks', async (t) => {
152+
const chunks = []
153+
154+
const peekStream = createLazyTransform((data) => {
155+
t.assert.equal(data.length, 4)
156+
t.assert.equal(data.toString(), 'abcd')
157+
return new PassThrough()
158+
}, 4)
159+
160+
peekStream.on('data', (chunk) => chunks.push(chunk))
161+
162+
await new Promise((resolve, reject) => {
163+
peekStream.on('end', resolve)
164+
peekStream.on('error', reject)
165+
peekStream.write(Buffer.from('ab'))
166+
peekStream.write(Buffer.from('cd'))
167+
peekStream.write(Buffer.from('ef'))
168+
peekStream.end()
169+
})
170+
171+
t.assert.equal(Buffer.concat(chunks).toString(), 'abcdef')
172+
})
173+
174+
test('createLazyTransform() should handle stream ending before maxBuffer is reached', async (t) => {
175+
const chunks = []
176+
177+
const peekStream = createLazyTransform((data) => {
178+
t.assert.equal(data.toString(), 'hi')
179+
return new PassThrough()
180+
})
181+
182+
peekStream.on('data', (chunk) => chunks.push(chunk))
183+
184+
await new Promise((resolve, reject) => {
185+
peekStream.on('end', resolve)
186+
peekStream.on('error', reject)
187+
peekStream.write(Buffer.from('hi'))
188+
peekStream.end()
189+
})
190+
191+
t.assert.equal(Buffer.concat(chunks).toString(), 'hi')
192+
})
193+
194+
test('createLazyTransform() should propagate errors from choose function', async (t) => {
195+
const peekStream = createLazyTransform(() => {
196+
throw new Error('test error')
197+
}, 2)
198+
199+
await t.assert.rejects(
200+
pipeline(Readable.from(Buffer.from('hello')), peekStream, new PassThrough()),
201+
{ message: 'test error' }
202+
)
203+
})
204+
205+
test('createLazyTransform() should handle empty stream', async (t) => {
206+
const chunks = []
207+
208+
const peekStream = createLazyTransform((data) => {
209+
t.assert.equal(data.length, 0)
210+
return new PassThrough()
211+
})
212+
213+
peekStream.on('data', (chunk) => chunks.push(chunk))
214+
215+
await new Promise((resolve, reject) => {
216+
peekStream.on('end', resolve)
217+
peekStream.on('error', reject)
218+
peekStream.end()
219+
})
220+
221+
t.assert.equal(Buffer.concat(chunks).length, 0)
222+
})
223+
224+
test('createLazyTransform() should work with a transform stream as destination', async (t) => {
225+
const chunks = []
226+
227+
const upper = new Transform({
228+
transform (chunk, enc, cb) {
229+
cb(null, chunk.toString().toUpperCase())
230+
}
231+
})
232+
233+
const peekStream = createLazyTransform(() => {
234+
return upper
235+
}, 3)
236+
237+
peekStream.on('data', (chunk) => chunks.push(chunk))
238+
239+
await new Promise((resolve, reject) => {
240+
peekStream.on('end', resolve)
241+
peekStream.on('error', reject)
242+
peekStream.write(Buffer.from('hello'))
243+
peekStream.end()
244+
})
245+
246+
t.assert.equal(Buffer.concat(chunks).toString(), 'HELLO')
247+
})

0 commit comments

Comments
 (0)