diff --git a/lib/constructor.mjs b/lib/constructor.mjs index 36cb745c9..f2b98696c 100644 --- a/lib/constructor.mjs +++ b/lib/constructor.mjs @@ -492,7 +492,7 @@ function clone () { clone.options.queueListener = queueListener; // Pass 'finish' event to clone for Stream-based input if (this._isStreamInput()) { - this.on('finish', () => { + this._whenStreamInFinished(() => { // Clone inherits input data this._flattenBufferIn(); clone.options.input.buffer = this.options.input.buffer; diff --git a/lib/input.mjs b/lib/input.mjs index 3ea76d032..0cab99dd4 100644 --- a/lib/input.mjs +++ b/lib/input.mjs @@ -531,11 +531,6 @@ function _createInputDescriptor (input, inputOptions, containerOptions) { function _write (chunk, _encoding, callback) { if (Array.isArray(this.options.input.buffer)) { if (is.buffer(chunk)) { - if (this.options.input.buffer.length === 0) { - this.on('finish', () => { - this.streamInFinished = true; - }); - } this.options.input.buffer.push(chunk); callback(); } else { @@ -565,6 +560,20 @@ function _isStreamInput () { return Array.isArray(this.options.input.buffer); } +/** + * Call fn when the Writable side of Stream-based input has finished, + * or immediately when it already has, as 'finish' is emitted only once. + * @private + * @param {Function} fn + */ +function _whenStreamInFinished (fn) { + if (this.writableFinished) { + fn(); + } else { + this.once('finish', fn); + } +} + /** * Fast access to (uncached) image metadata without decoding any compressed pixel data. * @@ -642,7 +651,7 @@ function metadata (callback) { const stack = Error(); if (is.fn(callback)) { if (this._isStreamInput()) { - this.on('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.metadata(this.options, (err, metadata) => { if (err) { @@ -665,7 +674,7 @@ function metadata (callback) { } else { if (this._isStreamInput()) { return new Promise((resolve, reject) => { - const finished = () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.metadata(this.options, (err, metadata) => { if (err) { @@ -674,12 +683,7 @@ function metadata (callback) { resolve(metadata); } }); - }; - if (this.writableFinished) { - finished(); - } else { - this.once('finish', finished); - } + }); }); } else { return new Promise((resolve, reject) => { @@ -744,7 +748,7 @@ function stats (callback) { const stack = Error(); if (is.fn(callback)) { if (this._isStreamInput()) { - this.on('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.stats(this.options, (err, stats) => { if (err) { @@ -767,7 +771,7 @@ function stats (callback) { } else { if (this._isStreamInput()) { return new Promise((resolve, reject) => { - this.on('finish', function () { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.stats(this.options, (err, stats) => { if (err) { @@ -805,6 +809,7 @@ export default (Sharp) => { _write, _flattenBufferIn, _isStreamInput, + _whenStreamInFinished, // Public metadata, stats diff --git a/lib/output.mjs b/lib/output.mjs index 00ff8b9e1..7372e62e1 100644 --- a/lib/output.mjs +++ b/lib/output.mjs @@ -1649,7 +1649,7 @@ function _pipeline (callback, stack) { // output=file/buffer if (this._isStreamInput()) { // output=file/buffer, input=stream - this.on('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.pipeline(this.options, (err, data, info) => { if (err) { @@ -1674,7 +1674,7 @@ function _pipeline (callback, stack) { // output=stream if (this._isStreamInput()) { // output=stream, input=stream - this.once('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.pipeline(this.options, (err, data, info) => { if (err) { @@ -1687,9 +1687,6 @@ function _pipeline (callback, stack) { this.on('end', () => this.emit('close')); }); }); - if (this.streamInFinished) { - this.emit('finish'); - } } else { // output=stream, input=file/buffer sharp.pipeline(this.options, (err, data, info) => { @@ -1709,7 +1706,7 @@ function _pipeline (callback, stack) { if (this._isStreamInput()) { // output=promise, input=stream return new Promise((resolve, reject) => { - this.once('finish', () => { + this._whenStreamInFinished(() => { this._flattenBufferIn(); sharp.pipeline(this.options, (err, data, info) => { if (err) { diff --git a/test/unit/io.js b/test/unit/io.js index 6ff2a9843..f314a1cf5 100644 --- a/test/unit/io.js +++ b/test/unit/io.js @@ -3,6 +3,7 @@ SPDX-License-Identifier: Apache-2.0 */ +const { once } = require('node:events'); const { createReadStream, createWriteStream } = require('node:fs'); const fs = require('node:fs/promises'); const path = require('node:path'); @@ -368,6 +369,81 @@ suite('Input/output', () => { await fs.rm(outputJpg); }); + suite('Output requested after Writable side of Stream-based input has finished', () => { + const finishedPipeline = async (pipeline) => { + createReadStream(fixtures.inputJpg).pipe(pipeline); + await once(pipeline, 'finish'); + return pipeline; + }; + + test('Write to Buffer via Promise', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp().resize(320, 240)); + const { info } = await pipeline.toBuffer({ resolveWithObject: true }); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + }); + + test('Write to Buffer via callback', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp().resize(320, 240)); + const info = await new Promise((resolve, reject) => { + pipeline.toBuffer((err, _data, info) => err ? reject(err) : resolve(info)); + }); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + }); + + test('Write to File via Promise', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp().resize(320, 240)); + const info = await pipeline.toFile(outputJpg); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + await fs.rm(outputJpg); + }); + + test('Read metadata via Promise', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { width } = await pipeline.metadata(); + t.assert.strictEqual(2725, width); + }); + + test('Read metadata via callback', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { width } = await new Promise((resolve, reject) => { + pipeline.metadata((err, metadata) => err ? reject(err) : resolve(metadata)); + }); + t.assert.strictEqual(2725, width); + }); + + test('Read stats via Promise', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { isOpaque } = await pipeline.stats(); + t.assert.strictEqual(true, isOpaque); + }); + + test('Read stats via callback', async (t) => { + t.plan(1); + const pipeline = await finishedPipeline(sharp()); + const { isOpaque } = await new Promise((resolve, reject) => { + pipeline.stats((err, stats) => err ? reject(err) : resolve(stats)); + }); + t.assert.strictEqual(true, isOpaque); + }); + + test('Clone inherits input', async (t) => { + t.plan(2); + const pipeline = await finishedPipeline(sharp()); + const { info } = await pipeline.clone().resize(320, 240).toBuffer({ resolveWithObject: true }); + t.assert.strictEqual(320, info.width); + t.assert.strictEqual(240, info.height); + }); + }); + test('Non-Stream input generates error when provided Stream-like data', async (t) => { t.plan(2); t.assert.throws(