From 77a6dede50c487006132e2fc086438fbac9bcc4b Mon Sep 17 00:00:00 2001 From: macgbluedev Date: Sat, 3 Oct 2026 17:06:26 -0400 Subject: [PATCH 1/2] feat(multipart): pass raw part headers to file and field events --- README.md | 4 ++-- lib/types/multipart.js | 4 ++-- test/part-headers.test.js | 47 +++++++++++++++++++++++++++++++++++++++ types/index.d.ts | 6 +++++ types/index.tst.ts | 35 +++++++++++++++++++---------- 5 files changed, 80 insertions(+), 16 deletions(-) create mode 100644 test/part-headers.test.js diff --git a/README.md b/README.md index 58ef778..1055d5d 100644 --- a/README.md +++ b/README.md @@ -183,7 +183,7 @@ _Busboy_ is a _Writable_ stream Busboy (special) events ----------------------- -* **file**(< _string_ >fieldname, < _ReadableStream_ >stream, < _string_ >filename, < _string_ >transferEncoding, < _string_ >mimeType) - Emitted for each new file form field found. `transferEncoding` contains the 'Content-Transfer-Encoding' value for the file stream. `mimeType` contains the 'Content-Type' value for the file stream. +* **file**(< _string_ >fieldname, < _ReadableStream_ >stream, < _string_ >filename, < _string_ >transferEncoding, < _string_ >mimeType, < _object_ >headers) - Emitted for each new file form field found. `transferEncoding` contains the 'Content-Transfer-Encoding' value for the file stream. `mimeType` contains the 'Content-Type' value for the file stream. `headers` contains the raw headers of the part (lower-cased keys, array values, original values such as the full `Content-Type` with its parameters). * Note: if you listen for this event, you should always handle the `stream` no matter if you care about the file contents or not (e.g. you can simply just do `stream.resume();` if you want to discard the contents), otherwise the 'finish' event will never fire on the Busboy instance. However, if you don't care about **any** incoming files, you can simply not listen for the 'file' event at all and any/all files will be automatically and safely discarded (these discarded files do still count towards `files` and `parts` limits). * If a configured file size limit was reached, `stream` will both have a boolean property `truncated` (best checked at the end of the stream) and emit a 'limit' event to notify you when this happens. * The property `bytesRead` informs about the number of bytes that have been read so far. @@ -198,7 +198,7 @@ busboy.on('file', (fieldname, stream) => { }) ``` -* **field**(< _string_ >fieldname, < _string_ >value, < _boolean_ >fieldnameTruncated, < _boolean_ >valueTruncated, < _string_ >transferEncoding, < _string_ >mimeType) - Emitted for each new non-file field found. +* **field**(< _string_ >fieldname, < _string_ >value, < _boolean_ >fieldnameTruncated, < _boolean_ >valueTruncated, < _string_ >transferEncoding, < _string_ >mimeType, < _object_ >headers) - Emitted for each new non-file field found. `headers` contains the raw headers of the part (see `file`). * **partsLimit**() - Emitted when specified `parts` limit has been reached. No more 'file' or 'field' events will be emitted. diff --git a/lib/types/multipart.js b/lib/types/multipart.js index 401f8af..58f1207 100644 --- a/lib/types/multipart.js +++ b/lib/types/multipart.js @@ -197,7 +197,7 @@ function Multipart (boy, cfg) { cb() } } - boy.emit('file', fieldname, file, filename, encoding, contype) + boy.emit('file', fieldname, file, filename, encoding, contype, header) onData = function (data) { if ((nsize += data.length) > fileSizeLimit) { @@ -245,7 +245,7 @@ function Multipart (boy, cfg) { onEnd = function () { curField = undefined if (buffer.length) { buffer = decodeText(buffer, 'binary', charset) } - boy.emit('field', fieldname, buffer, false, truncated, encoding, contype) + boy.emit('field', fieldname, buffer, false, truncated, encoding, contype, header) --nends checkFinished() } diff --git a/test/part-headers.test.js b/test/part-headers.test.js new file mode 100644 index 0000000..5f1c420 --- /dev/null +++ b/test/part-headers.test.js @@ -0,0 +1,47 @@ +'use strict' + +const Busboy = require('..') + +const { test } = require('node:test') + +test('field and file events expose the raw part headers', (t, done) => { + t.plan(8) + const boundary = '----busboyHeaders' + const busboy = new Busboy({ + headers: { 'content-type': 'multipart/form-data; boundary=' + boundary } + }) + + busboy.on('field', (name, val, nameTrunc, valTrunc, encoding, mimeType, headers) => { + t.assert.strictEqual(name, 'text') + t.assert.strictEqual(mimeType, 'text/plain') + t.assert.deepStrictEqual(headers['content-type'], ['Text/Plain; charset=UTF-8']) + t.assert.deepStrictEqual(headers['x-custom'], ['a', 'b']) + }) + busboy.on('file', (name, stream, filename, encoding, mimeType, headers) => { + t.assert.strictEqual(mimeType, 'text/plain') + t.assert.deepStrictEqual(headers['content-type'], ['text/plain;charset=utf-16']) + t.assert.deepStrictEqual(headers['content-disposition'], ['form-data; name="f"; filename="a.txt"']) + stream.resume() + }) + busboy.on('finish', () => { + t.assert.ok(true) + done() + }) + + busboy.end([ + '--' + boundary, + 'Content-Disposition: form-data; name="text"', + 'Content-Type: Text/Plain; charset=UTF-8', + 'X-Custom: a', + 'X-Custom: b', + '', + 'hello', + '--' + boundary, + 'Content-Disposition: form-data; name="f"; filename="a.txt"', + 'Content-Type: text/plain;charset=utf-16', + '', + 'data', + '--' + boundary + '--', + '' + ].join('\r\n')) +}) \ No newline at end of file diff --git a/types/index.d.ts b/types/index.d.ts index 7c6c513..7d01800 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -109,6 +109,8 @@ export interface BusboyConfig { export type BusboyHeaders = { 'content-type': string } & http.IncomingHttpHeaders +export type BusboyPartHeaders = Record + export interface BusboyFileStream extends Readable { @@ -194,6 +196,8 @@ export interface BusboyEvents { * * @param listener.transferEncoding Contains the 'Content-Transfer-Encoding' value for the file stream. * @param listener.mimeType Contains the 'Content-Type' value for the file stream. + * @param listener.headers The raw headers of the part. Keys are lower-cased and each value is an array + * (repeated headers are preserved); values are not modified, e.g. `content-type` keeps its parameters and casing. */ file: ( fieldname: string, @@ -201,6 +205,7 @@ export interface BusboyEvents { filename: string, transferEncoding: string, mimeType: string, + headers: BusboyPartHeaders, ) => void; /** * Emitted for each new non-file field found. @@ -212,6 +217,7 @@ export interface BusboyEvents { valueTruncated: boolean, transferEncoding: string, mimeType: string, + headers: BusboyPartHeaders, ) => void; finish: () => void; /** diff --git a/types/index.tst.ts b/types/index.tst.ts index 634d619..39c7efd 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -1,6 +1,6 @@ /* eslint-disable import-x/no-duplicates */ /* eslint-disable no-new */ -import BusboyDefault, { Busboy, BusboyFileStream } from '.' +import BusboyDefault, { Busboy, BusboyFileStream, BusboyPartHeaders } from '.' import { expect } from 'tstyche' import BusboyESM from '.' @@ -60,7 +60,8 @@ new BusboyDefault({ isPartAFile: (fieldName, contentType, fileName) => fileName !== undefined }) -busboy.addListener('file', (fieldname, file, filename, encoding, mimetype) => { +busboy.addListener('file', (fieldname, file, filename, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(file).type.toBe() expect(filename).type.toBe() @@ -69,7 +70,8 @@ busboy.addListener('file', (fieldname, file, filename, encoding, mimetype) => { }) busboy.addListener( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() @@ -93,7 +95,8 @@ busboy.on(Symbol('foo'), (foo) => { expect(foo).type.toBe() }) -busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { +busboy.on('file', (fieldname, file, filename, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(file).type.toBe() expect(filename).type.toBe() @@ -102,7 +105,8 @@ busboy.on('file', (fieldname, file, filename, encoding, mimetype) => { }) busboy.on( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() @@ -126,7 +130,8 @@ busboy.on(Symbol('foo'), (foo) => { expect(foo).type.toBe() }) -busboy.once('file', (fieldname, file, filename, encoding, mimetype) => { +busboy.once('file', (fieldname, file, filename, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(file).type.toBe() expect(filename).type.toBe() @@ -135,7 +140,8 @@ busboy.once('file', (fieldname, file, filename, encoding, mimetype) => { }) busboy.once( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() @@ -171,7 +177,8 @@ busboy.removeListener( ) busboy.removeListener( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() @@ -195,7 +202,8 @@ busboy.removeListener(Symbol('foo'), (foo) => { expect(foo).type.toBe() }) -busboy.off('file', (fieldname, file, filename, encoding, mimetype) => { +busboy.off('file', (fieldname, file, filename, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(file).type.toBe() expect(filename).type.toBe() @@ -204,7 +212,8 @@ busboy.off('file', (fieldname, file, filename, encoding, mimetype) => { }) busboy.off( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() @@ -240,7 +249,8 @@ busboy.prependListener( ) busboy.prependListener( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() @@ -276,7 +286,8 @@ busboy.prependOnceListener( ) busboy.prependOnceListener( 'field', - (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => { + (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype, headers) => { + expect(headers).type.toBe() expect(fieldname).type.toBe() expect(val).type.toBe() expect(fieldnameTruncated).type.toBe() From 050e1f9db3bb380350606ba72c54ba2f792d0e5e Mon Sep 17 00:00:00 2001 From: macgbluedev Date: Tue, 13 Oct 2026 00:01:41 -0400 Subject: [PATCH 2/2] style(test): add missing newline at end of part-headers test Fixes the @stylistic/eol-last lint error that failed CI. Refs: #127 --- test/part-headers.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/part-headers.test.js b/test/part-headers.test.js index 5f1c420..2a20ce8 100644 --- a/test/part-headers.test.js +++ b/test/part-headers.test.js @@ -44,4 +44,4 @@ test('field and file events expose the raw part headers', (t, done) => { '--' + boundary + '--', '' ].join('\r\n')) -}) \ No newline at end of file +})