diff --git a/package-lock.json b/package-lock.json index e0e4b73..97dc748 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18547,6 +18547,7 @@ "version": "2.1.2", "license": "EUPL-1.2", "dependencies": { + "form-data": "^4.0.6", "tough-cookie": "^6.0.0" }, "devDependencies": { diff --git a/packages/fetch/__tests__/post-form-async-memory-leak.test.ts b/packages/fetch/__tests__/post-form-async-memory-leak.test.ts new file mode 100644 index 0000000..a1876d1 --- /dev/null +++ b/packages/fetch/__tests__/post-form-async-memory-leak.test.ts @@ -0,0 +1,67 @@ +import { testApp } from '@digabi/testing' +import express from 'express' +import { test, describe, before, after } from 'node:test' +import assert from 'node:assert/strict' +import * as requestWrappers from '../src' + +/** + * Regression test for https://github.com/nodejs/node/issues/63574: passing a + * Blob/FormData body to fetch() permanently leaks its backing buffer on + * affected Node versions (confirmed on 24.17.0, not yet fixed upstream in any + * released 24.x/26.x). postFormAsync must never route large file uploads + * through that code path again. + */ +describe('postFormAsync memory usage', () => { + const memoryTestApp = testApp.testAppContext() + + before(() => { + const app = express() + + app.post('/upload', (req, res) => { + req.resume() + req.on('end', () => res.status(201).json({ ok: true })) + }) + + return memoryTestApp.initApp(app)() + }) + + after(memoryTestApp.closeApp) + + test( + 'does not leak memory across many large file uploads', + { skip: !global.gc && 'run with --expose-gc for a meaningful assertion' }, + async () => { + const PAYLOAD_MB = 5 + const WARMUP_ITERATIONS = 5 + const ITERATIONS = 30 + const payload = Buffer.alloc(PAYLOAD_MB * 1024 * 1024) + + async function upload() { + await requestWrappers.postFormAsync(`${memoryTestApp.getServerPrefix()}/upload`, { + examZip: { value: payload, options: { filename: 'exam.zip', contentType: 'application/octet-stream' } }, + userId: 'test-user' + }) + } + + function arrayBuffersAfterGc() { + global.gc?.() + return process.memoryUsage().arrayBuffers + } + + for (let i = 0; i < WARMUP_ITERATIONS; i++) await upload() + const baseline = arrayBuffersAfterGc() + + for (let i = 0; i < ITERATIONS; i++) await upload() + const afterMany = arrayBuffersAfterGc() + + const growthMB = (afterMany - baseline) / 1024 / 1024 + + assert.ok( + growthMB < PAYLOAD_MB * 2, + `arrayBuffers grew by ${growthMB.toFixed(1)}MB over ${ITERATIONS} uploads after a ${WARMUP_ITERATIONS}-iteration ` + + `warmup (baseline ${(baseline / 1024 / 1024).toFixed(1)}MB) - expected it to stay roughly flat. ` + + `See https://github.com/nodejs/node/issues/63574.` + ) + } + ) +}) diff --git a/packages/fetch/package.json b/packages/fetch/package.json index e6e9a96..9cb0972 100644 --- a/packages/fetch/package.json +++ b/packages/fetch/package.json @@ -13,11 +13,12 @@ ], "scripts": { "build": "tsc --project ./tsconfig.build.json", - "test": "node --test --import tsx ./__tests__/*.test.ts", + "test": "node --expose-gc --test --import tsx ./__tests__/*.test.ts", "lint": "tsc --noEmit && eslint ./", "prepack": "npm run build" }, "dependencies": { + "form-data": "^4.0.6", "tough-cookie": "^6.0.0" }, "devDependencies": { diff --git a/packages/fetch/src/index.ts b/packages/fetch/src/index.ts index af56ab0..ac1841e 100644 --- a/packages/fetch/src/index.ts +++ b/packages/fetch/src/index.ts @@ -1,3 +1,4 @@ +import NodeFormData from 'form-data' import { CookieJar } from 'tough-cookie' export type FetchOptions = RequestInit & { @@ -38,24 +39,25 @@ function isFileObjectOrFileObjectArray( return Array.isArray(obj) || obj instanceof Object } -function addFilesToFormData(formData: FormData, key: string, files: FileObject[]) { +function addFilesToFormData(formData: NodeFormData, key: string, files: FileObject[]) { files.forEach(file => { - formData.append(key, new Blob([file.value], { type: file.options.contentType }), file.options.filename) + formData.append(key, file.value, { filename: file.options.filename, contentType: file.options.contentType }) }) } -function formObjectToFormData(body: FormObject): FormData { - const formData = new FormData() +function formObjectToMultipartBody(body: FormObject): { body: Buffer; headers: Record } { + const formData = new NodeFormData() Object.entries(body).forEach(([key, value]) => { if (isFileObjectOrFileObjectArray(value)) { addFilesToFormData(formData, key, Array.isArray(value) ? value : [value]) } else { - formData.append(key, value) + // form-data coerces number/null/undefined itself but not boolean - it'll throw on a raw true/false + formData.append(key, String(value)) } }) - return formData + return { body: formData.getBuffer(), headers: formData.getHeaders() } } function isContentTypeApplicationJson(res: Response) { @@ -276,12 +278,15 @@ export function postFormAsync( options: FetchOptions | undefined = {}, fullResponse = false ) { + const { body: requestBody, headers } = isFormObject(body) ? formObjectToMultipartBody(body) : { body, headers: {} } + return requestJsonAsync( url, 'POST', { ...options, - body: isFormObject(body) ? formObjectToFormData(body) : body + headers: { ...options.headers, ...headers }, + body: requestBody }, fullResponse, 'json'