From 7b5cdc7daef99010336b40770fcf51a9e414a2bc Mon Sep 17 00:00:00 2001 From: Kazu <64197690+ZawaPaP@users.noreply.github.com> Date: Sat, 31 Jan 2026 15:30:14 +0900 Subject: [PATCH 1/6] feat: add transformRequest option for custom request streams --- index.js | 9 +++- test/multipart-transform-request.test.js | 66 ++++++++++++++++++++++++ types/index.d.ts | 10 ++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 test/multipart-transform-request.test.js diff --git a/index.js b/index.js index 283510dc..d5859bb4 100644 --- a/index.js +++ b/index.js @@ -171,6 +171,10 @@ function fastifyMultipart (fastify, options, done) { ? options.throwFileSizeLimit : true + const defaultTransformRequest = (request) => request + + const transformRequest = options.transformRequest || defaultTransformRequest + fastify.decorate('multipartErrors', { PartsLimitError, FilesLimitError, @@ -297,7 +301,8 @@ function fastifyMultipart (fastify, options, done) { process.nextTick(() => cleanup(err)) }) - request.pipe(bb) + const stream = transformRequest(request) + stream.pipe(bb) function onField (name, fieldValue, fieldnameTruncated, valueTruncated, encoding, contentType) { // don't overwrite prototypes @@ -435,7 +440,7 @@ function fastifyMultipart (fastify, options, done) { } function cleanup (err) { - request.unpipe(bb) + stream.unpipe(bb) if ((err || request.aborted) && currentFile) { currentFile.destroy() diff --git a/test/multipart-transform-request.test.js b/test/multipart-transform-request.test.js new file mode 100644 index 00000000..b10b8eef --- /dev/null +++ b/test/multipart-transform-request.test.js @@ -0,0 +1,66 @@ +'use strict' + +const test = require('node:test') +const FormData = require('form-data') +const Fastify = require('fastify') +const multipart = require('..') +const http = require('node:http') +const path = require('node:path') +const fs = require('node:fs') +const streamToNull = require('../lib/stream-consumer') + +const filePath = path.join(__dirname, '../README.md') + +test('should transformRequest called when option passed', function (t, done) { + t.plan(3) + + let transformRequestCalled = false + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + transformRequestCalled = true + return request + } + }) + + fastify.post('/', async function (req, reply) { + const parts = req.parts() + + for await (const part of parts) { + if (part.file) { + await streamToNull(part.file) + } + } + + t.assert.ok(transformRequestCalled, 'transformRequest should have been called') + reply.code(200).send() + }) + + fastify.listen({ port: 0 }, function () { + // request + const form = new FormData() + const opts = { + protocol: 'http:', + hostname: 'localhost', + port: fastify.server.address().port, + path: '/', + headers: form.getHeaders(), + method: 'POST' + } + + const req = http.request(opts, (res) => { + t.assert.strictEqual(res.statusCode, 200) + res.resume() + res.on('end', () => { + t.assert.ok('res ended successfully') + done() + }) + }) + form.append('upload', fs.createReadStream(filePath)) + form.append('hello', 'world') + + form.pipe(req) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 67f7cbc0..8a802464 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -162,6 +162,16 @@ declare namespace fastifyMultipart { * @default 1000 */ parts?: number; + + /** + * Provide a custom stream for multipart parsing + * + * By default, the request stream is used directly + * + * Useful for environments like Google Cloud Functions + * where the request body has already been consumed + */ + transformRequest?: (request: Readable) => Readable; }; } From da7d4a036fea66dd546fa4c0ec248c4e057b5384 Mon Sep 17 00:00:00 2001 From: Kazu <64197690+ZawaPaP@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:06:20 +0900 Subject: [PATCH 2/6] docs: add type test and README for transformRequest --- README.md | 10 ++++++++++ types/index.test-d.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 37b67cc1..01de65b9 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,16 @@ try { ``` +You can provide a custom stream using the `transformRequest` option. This is useful for environments like Google Cloud Functions where the request body has already been consumed: + +```js +const { Readable } = require('node:stream') + +fastify.register(require('@fastify/multipart'), { + transformRequest: (request) => Readable.from(request.rawBody) +}) +``` + Additionally, you can pass per-request options to the `req.file`, `req.files`, `req.saveRequestFiles` or `req.parts` function. ```js diff --git a/types/index.test-d.ts b/types/index.test-d.ts index 078cee6c..f6226550 100644 --- a/types/index.test-d.ts +++ b/types/index.test-d.ts @@ -2,7 +2,7 @@ import fastify from 'fastify' import fastifyMultipart, { MultipartValue, MultipartFields, MultipartFile } from '..' import * as util from 'node:util' -import { pipeline } from 'node:stream' +import { pipeline, Readable } from 'node:stream' import * as fs from 'node:fs' import { expectError, expectType } from 'tsd' import { FastifyErrorConstructor } from '@fastify/error' @@ -19,6 +19,10 @@ const runServer = async () => { limits: { parts: 500 }, + transformRequest: (request) => { + expectType(request) + return request + }, onFile: (part: MultipartFile) => { console.log(part) } From ba39e00ce3a84cc66b2a55c504664d7c2b92e8bb Mon Sep 17 00:00:00 2001 From: Kazu <64197690+ZawaPaP@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:13:20 +0900 Subject: [PATCH 3/6] remove type option to fix the place --- test/multipart-transform-request.test.js | 56 ++++++++++++++++++++++++ types/index.d.ts | 20 ++++----- 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/test/multipart-transform-request.test.js b/test/multipart-transform-request.test.js index b10b8eef..d77b57b8 100644 --- a/test/multipart-transform-request.test.js +++ b/test/multipart-transform-request.test.js @@ -8,6 +8,7 @@ const http = require('node:http') const path = require('node:path') const fs = require('node:fs') const streamToNull = require('../lib/stream-consumer') +const { Readable } = require('node:stream') const filePath = path.join(__dirname, '../README.md') @@ -64,3 +65,58 @@ test('should transformRequest called when option passed', function (t, done) { form.pipe(req) }) }) + +test('should use custom stream from transformRequest', function (t, done) { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + const boundary = '----TestBoundary' + const payload = Buffer.from( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + 'test content\r\n' + + `--${boundary}--\r\n` + ) + + fastify.register(multipart, { + transformRequest: (request) => { + return Readable.from(payload) + } + }) + + fastify.post('/', async function (req, reply) { + const file = await req.file() + const content = await file.toBuffer() + t.assert.strictEqual(content.toString(), 'test content') + + reply.code(200).send() + }) + + fastify.listen({ port: 0 }, function () { + // request + const form = new FormData() + const opts = { + protocol: 'http:', + hostname: 'localhost', + port: fastify.server.address().port, + path: '/', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + method: 'POST' + } + + const req = http.request(opts, (res) => { + t.assert.strictEqual(res.statusCode, 200) + res.resume() + res.on('end', () => { + t.assert.ok('res ended successfully') + done() + }) + }) + + form.pipe(req) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 8a802464..f5696140 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -162,17 +162,17 @@ declare namespace fastifyMultipart { * @default 1000 */ parts?: number; - - /** - * Provide a custom stream for multipart parsing - * - * By default, the request stream is used directly - * - * Useful for environments like Google Cloud Functions - * where the request body has already been consumed - */ - transformRequest?: (request: Readable) => Readable; }; + + /** + * Provide a custom stream for multipart parsing + * + * By default, the request stream is used directly + * + * Useful for environments like Google Cloud Functions + * where the request body has already been consumed + */ + transformRequest?: (request: Readable) => Readable; } export interface FastifyMultipartOptions extends FastifyMultipartBaseOptions { From 36ca0add0932561cc9b25bab42c258dd814b56c1 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 10 Feb 2026 00:20:51 +0100 Subject: [PATCH 4/6] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Matteo Collina --- README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.md b/README.md index 01de65b9..19be3321 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,19 @@ You can provide a custom stream using the `transformRequest` option. This is use ```js const { Readable } = require('node:stream') +// In environments like Google Cloud Functions/Firebase, `request.rawBody` +// contains the full raw request body buffer/string. fastify.register(require('@fastify/multipart'), { transformRequest: (request) => Readable.from(request.rawBody) }) + +// In a standard Fastify/Node.js server, `request.raw` is already a readable +// stream for the incoming HTTP request, so you can usually omit +// `transformRequest` entirely or simply return `request.raw`: +// +// fastify.register(require('@fastify/multipart'), { +// transformRequest: (request) => request.raw +// }) ``` Additionally, you can pass per-request options to the `req.file`, `req.files`, `req.saveRequestFiles` or `req.parts` function. From 1c2341cadac1589eae3f990b51130b2437896cc5 Mon Sep 17 00:00:00 2001 From: Kazu <64197690+ZawaPaP@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:02:33 +0900 Subject: [PATCH 5/6] add test and error handling --- index.js | 27 +- test/multipart-transform-request.test.js | 354 ++++++++++++++++++++--- 2 files changed, 335 insertions(+), 46 deletions(-) diff --git a/index.js b/index.js index d5859bb4..4b7ca560 100644 --- a/index.js +++ b/index.js @@ -10,7 +10,7 @@ const { generateId } = require('./lib/generateId') const createError = require('@fastify/error') const streamToNull = require('./lib/stream-consumer') const deepmergeAll = require('@fastify/deepmerge')({ all: true }) -const { PassThrough, Readable } = require('node:stream') +const { PassThrough, Readable, pipeline } = require('node:stream') const { pipeline: pump } = require('node:stream/promises') const secureJSON = require('secure-json-parse') @@ -26,6 +26,7 @@ const InvalidMultipartContentTypeError = createError('FST_INVALID_MULTIPART_CONT const InvalidJSONFieldError = createError('FST_INVALID_JSON_FIELD_ERROR', 'a request field is not a valid JSON as declared by its Content-Type', 406) const FileBufferNotFoundError = createError('FST_FILE_BUFFER_NOT_FOUND', 'the file buffer was not found', 500) const NoFormData = createError('FST_NO_FORM_DATA', 'FormData is not available', 500) +const InvalidTransformRequestError = createError('FST_INVALID_TRANSFORM_REQUEST', 'transformRequest must return a readable stream', 500) function setMultipart (req, _payload, done) { req[kMultipart] = true @@ -171,9 +172,17 @@ function fastifyMultipart (fastify, options, done) { ? options.throwFileSizeLimit : true - const defaultTransformRequest = (request) => request + const userTransformRequest = typeof options.transformRequest === 'function' ? options.transformRequest : (request) => request - const transformRequest = options.transformRequest || defaultTransformRequest + const transformRequest = (request) => { + try { + const stream = userTransformRequest(request) + if (!stream || typeof stream.pipe !== 'function') throw new InvalidTransformRequestError() + return stream + } catch (error) { + return error + } + } fastify.decorate('multipartErrors', { PartsLimitError, @@ -302,7 +311,15 @@ function fastifyMultipart (fastify, options, done) { }) const stream = transformRequest(request) - stream.pipe(bb) + if (stream instanceof Error) { + onError(stream) + process.nextTick(() => cleanup(stream)) + return + } + + pipeline(stream, bb, (err) => { + cleanup(err) + }) function onField (name, fieldValue, fieldnameTruncated, valueTruncated, encoding, contentType) { // don't overwrite prototypes @@ -440,8 +457,6 @@ function fastifyMultipart (fastify, options, done) { } function cleanup (err) { - stream.unpipe(bb) - if ((err || request.aborted) && currentFile) { currentFile.destroy() currentFile = null diff --git a/test/multipart-transform-request.test.js b/test/multipart-transform-request.test.js index d77b57b8..f310c365 100644 --- a/test/multipart-transform-request.test.js +++ b/test/multipart-transform-request.test.js @@ -1,19 +1,23 @@ 'use strict' const test = require('node:test') -const FormData = require('form-data') +const { Transform } = require('node:stream') const Fastify = require('fastify') const multipart = require('..') const http = require('node:http') -const path = require('node:path') -const fs = require('node:fs') -const streamToNull = require('../lib/stream-consumer') -const { Readable } = require('node:stream') -const filePath = path.join(__dirname, '../README.md') +const boundary = '----TestBoundary' +const payload = Buffer.from( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + 'test content\r\n' + + `--${boundary}--\r\n` +) test('should transformRequest called when option passed', function (t, done) { - t.plan(3) + t.plan(4) let transformRequestCalled = false const fastify = Fastify() @@ -27,27 +31,22 @@ test('should transformRequest called when option passed', function (t, done) { }) fastify.post('/', async function (req, reply) { - const parts = req.parts() - - for await (const part of parts) { - if (part.file) { - await streamToNull(part.file) - } - } + const file = await req.file() + t.assert.strictEqual(file.filename, 'test.txt') + const content = await file.toBuffer() + t.assert.strictEqual(content.toString(), 'test content') t.assert.ok(transformRequestCalled, 'transformRequest should have been called') reply.code(200).send() }) fastify.listen({ port: 0 }, function () { - // request - const form = new FormData() const opts = { protocol: 'http:', hostname: 'localhost', port: fastify.server.address().port, path: '/', - headers: form.getHeaders(), + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, method: 'POST' } @@ -55,50 +54,74 @@ test('should transformRequest called when option passed', function (t, done) { t.assert.strictEqual(res.statusCode, 200) res.resume() res.on('end', () => { - t.assert.ok('res ended successfully') done() }) }) - form.append('upload', fs.createReadStream(filePath)) - form.append('hello', 'world') - form.pipe(req) + req.end(payload) }) }) -test('should use custom stream from transformRequest', function (t, done) { +test('should transform the request stream', function (t, done) { t.plan(3) - const fastify = Fastify() t.after(() => fastify.close()) - const boundary = '----TestBoundary' - const payload = Buffer.from( - `--${boundary}\r\n` + - 'Content-Disposition: form-data; name="file"; filename="test.txt"\r\n' + - 'Content-Type: text/plain\r\n' + - '\r\n' + - 'test content\r\n' + - `--${boundary}--\r\n` - ) - fastify.register(multipart, { transformRequest: (request) => { - return Readable.from(payload) + // custom transfrom function to change content to uppercase + const upperCaseTr = new Transform({ + transform (chunk, encoding, callback) { + callback(null, chunk.toString().toUpperCase()) + } + }) + return request.pipe(upperCaseTr) } }) - fastify.post('/', async function (req, reply) { + fastify.post('/', async (req) => { const file = await req.file() + t.assert.strictEqual(file.filename, 'TEST.TXT') + const content = await file.toBuffer() - t.assert.strictEqual(content.toString(), 'test content') + t.assert.strictEqual(content.toString().trim(), 'TEST CONTENT') + + return { ok: true } + }) + + fastify.listen({ port: 0 }, () => { + const req = http.request({ + port: fastify.server.address().port, + method: 'POST', + headers: { 'content-type': `multipart/form-data; boundary=${boundary.toUpperCase()}` } + }, (res) => { + t.assert.strictEqual(res.statusCode, 200) + res.resume() + res.on('end', done) + }) + req.end(payload) + }) +}) + +test('should handle transformRequest throwing an error', function (t, done) { + t.plan(2) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + throw new Error('transformRequest failed') + } + }) + fastify.post('/', async function (req, reply) { + const file = await req.file() + await file.toBuffer() reply.code(200).send() }) fastify.listen({ port: 0 }, function () { - // request - const form = new FormData() const opts = { protocol: 'http:', hostname: 'localhost', @@ -109,7 +132,7 @@ test('should use custom stream from transformRequest', function (t, done) { } const req = http.request(opts, (res) => { - t.assert.strictEqual(res.statusCode, 200) + t.assert.strictEqual(res.statusCode, 500) res.resume() res.on('end', () => { t.assert.ok('res ended successfully') @@ -117,6 +140,257 @@ test('should use custom stream from transformRequest', function (t, done) { }) }) - form.pipe(req) + req.end(payload) + }) +}) + +test('should throw transformRequest returning undefined', function (t, done) { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + return undefined + } + }) + + fastify.post('/', async function (req, reply) { + t.assert.ok(req.isMultipart()) + + try { + const file = await req.file() + await file.toBuffer() + reply.code(200).send() + } catch (error) { + t.assert.strictEqual(error.code, 'FST_INVALID_TRANSFORM_REQUEST') + reply.code(500).send() + } + }) + + fastify.listen({ port: 0 }, function () { + const opts = { + protocol: 'http:', + hostname: 'localhost', + port: fastify.server.address().port, + path: '/', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + method: 'POST' + } + + const req = http.request(opts, (res) => { + t.assert.strictEqual(res.statusCode, 500) + res.resume() + res.on('end', done) + }) + + req.end(payload) + }) +}) + +test('should throw transformRequest returning null', function (t, done) { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + return null + } + }) + + fastify.post('/', async function (req, reply) { + t.assert.ok(req.isMultipart()) + + try { + const file = await req.file() + await file.toBuffer() + reply.code(200).send() + } catch (error) { + t.assert.strictEqual(error.code, 'FST_INVALID_TRANSFORM_REQUEST') + reply.code(500).send() + } + }) + + fastify.listen({ port: 0 }, function () { + const opts = { + protocol: 'http:', + hostname: 'localhost', + port: fastify.server.address().port, + path: '/', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + method: 'POST' + } + + const req = http.request(opts, (res) => { + t.assert.strictEqual(res.statusCode, 500) + res.resume() + res.on('end', done) + }) + + req.end(payload) + }) +}) + +test('should throw transformRequest returning non-streaming data', function (t, done) { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + return 'non-streaming data' + } + }) + + fastify.post('/', async function (req, reply) { + t.assert.ok(req.isMultipart()) + + try { + const file = await req.file() + await file.toBuffer() + reply.code(200).send() + } catch (error) { + t.assert.strictEqual(error.code, 'FST_INVALID_TRANSFORM_REQUEST') + reply.code(500).send() + } + }) + + fastify.listen({ port: 0 }, function () { + const opts = { + protocol: 'http:', + hostname: 'localhost', + port: fastify.server.address().port, + path: '/', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` }, + method: 'POST' + } + + const req = http.request(opts, (res) => { + t.assert.strictEqual(res.statusCode, 500) + res.resume() + res.on('end', done) + }) + + req.end(payload) + }) +}) + +test('should handle transformed stream closing prematurely', function (t, done) { + t.plan(3) + + const largeContent = Buffer.alloc(1024, 'x').toString() + const largePayload = Buffer.from( + `--${boundary}\r\n` + + 'Content-Disposition: form-data; name="file"; filename="large.txt"\r\n' + + 'Content-Type: text/plain\r\n' + + '\r\n' + + largeContent + '\r\n' + + `--${boundary}--\r\n` + ) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + const prematureStream = new Transform({ + transform (chunk, encoding, callback) { + process.nextTick(() => this.destroy()) + callback(null, chunk) + } + }) + return request.pipe(prematureStream) + } + }) + + fastify.post('/', async function (req, reply) { + t.assert.ok(req.isMultipart()) + + try { + const file = await req.file() + await file.toBuffer() + reply.code(200).send() + } catch (error) { + t.assert.strictEqual(error.code, 'ERR_STREAM_PREMATURE_CLOSE') + reply.code(500).send() + } + }) + + fastify.listen({ port: 0 }, function () { + let finished = false + const safeDone = () => { + if (!finished) { + finished = true + done() + } + } + + const req = http.request({ + port: fastify.server.address().port, + method: 'POST', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } + }, (res) => { + t.assert.strictEqual(res.statusCode, 500) + res.resume() + res.on('end', done) + }) + + req.on('error', (err) => { + if (!finished) { + t.assert.ok(err.message.includes('EPIPE') || err.message.includes('reset')) + safeDone() + } + }) + + req.end(largePayload) + }) +}) + +test('should handle transformed stream emitting an error during processing', function (t, done) { + t.plan(3) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(multipart, { + transformRequest: (request) => { + const errStream = new Transform({ + transform (chunk, encoding, callback) { + callback(new Error('stream processing failed')) + } + }) + return request.pipe(errStream) + } + }) + + fastify.post('/', async function (req, reply) { + t.assert.ok(req.isMultipart()) + + try { + const file = await req.file() + await file.toBuffer() + reply.code(200).send() + } catch (error) { + t.assert.strictEqual(error.message, 'stream processing failed') + reply.code(500).send() + } + }) + + fastify.listen({ port: 0 }, function () { + const req = http.request({ + port: fastify.server.address().port, + method: 'POST', + headers: { 'content-type': `multipart/form-data; boundary=${boundary}` } + }, (res) => { + t.assert.strictEqual(res.statusCode, 500) + res.resume() + res.on('end', done) + }) + + req.end(payload) }) }) From 88a26152c325a33c632975a54bfd713614a9a8f1 Mon Sep 17 00:00:00 2001 From: Kazu <64197690+ZawaPaP@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:22:21 +0900 Subject: [PATCH 6/6] simplify error handling --- index.js | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/index.js b/index.js index 4b7ca560..415a1661 100644 --- a/index.js +++ b/index.js @@ -172,17 +172,7 @@ function fastifyMultipart (fastify, options, done) { ? options.throwFileSizeLimit : true - const userTransformRequest = typeof options.transformRequest === 'function' ? options.transformRequest : (request) => request - - const transformRequest = (request) => { - try { - const stream = userTransformRequest(request) - if (!stream || typeof stream.pipe !== 'function') throw new InvalidTransformRequestError() - return stream - } catch (error) { - return error - } - } + const transformRequest = typeof options.transformRequest === 'function' ? options.transformRequest : (request) => request fastify.decorate('multipartErrors', { PartsLimitError, @@ -311,11 +301,7 @@ function fastifyMultipart (fastify, options, done) { }) const stream = transformRequest(request) - if (stream instanceof Error) { - onError(stream) - process.nextTick(() => cleanup(stream)) - return - } + if (!stream || typeof stream.pipe !== 'function') throw new InvalidTransformRequestError() pipeline(stream, bb, (err) => { cleanup(err)