Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions packages/fetch/__tests__/post-form-async-memory-leak.test.ts
Original file line number Diff line number Diff line change
@@ -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.`
)
}
)
})
3 changes: 2 additions & 1 deletion packages/fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
19 changes: 12 additions & 7 deletions packages/fetch/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import NodeFormData from 'form-data'
import { CookieJar } from 'tough-cookie'

export type FetchOptions = RequestInit & {
Expand Down Expand Up @@ -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<string, string> } {
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) {
Expand Down Expand Up @@ -276,12 +278,15 @@ export function postFormAsync<T>(
options: FetchOptions | undefined = {},
fullResponse = false
) {
const { body: requestBody, headers } = isFormObject(body) ? formObjectToMultipartBody(body) : { body, headers: {} }

return requestJsonAsync<T>(
url,
'POST',
{
...options,
body: isFormObject(body) ? formObjectToFormData(body) : body
headers: { ...options.headers, ...headers },
body: requestBody
},
fullResponse,
'json'
Expand Down
Loading