From 937c73c9636a5feea418844a2a4ee44d181d6667 Mon Sep 17 00:00:00 2001 From: baptiste Date: Sun, 1 Feb 2026 12:31:39 +0000 Subject: [PATCH] fix: buffer tar.gz stream to prevent Content-Length mismatch in file upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tarFileStreamUpload called tarFileStream twice — once to calculate Content-Length by consuming the stream, then again to create the upload body. Since gzip compression is non-deterministic (internal dictionary state, timing), the second stream can produce a different byte count, causing fetch to throw RequestContentLengthMismatchError. Buffer the stream into memory on the first pass and reuse that buffer for both the content length and the upload body. --- packages/js-sdk/src/template/utils.ts | 49 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/js-sdk/src/template/utils.ts b/packages/js-sdk/src/template/utils.ts index 34670767c4..572ea65050 100644 --- a/packages/js-sdk/src/template/utils.ts +++ b/packages/js-sdk/src/template/utils.ts @@ -1,5 +1,6 @@ import crypto from 'node:crypto' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { dynamicImport, dynamicRequire } from '../utils' import { TemplateError } from '../errors' @@ -342,26 +343,46 @@ export async function tarFileStreamUpload( ignorePatterns: string[], resolveSymlinks: boolean ) { - // First pass: calculate the compressed size - const sizeCalculationStream = await tarFileStream( + const stream = await tarFileStream( fileName, fileContextPath, ignorePatterns, resolveSymlinks ) - let contentLength = 0 - for await (const chunk of sizeCalculationStream as unknown as AsyncIterable) { - contentLength += chunk.length - } - return { - contentLength, - uploadStream: await tarFileStream( - fileName, - fileContextPath, - ignorePatterns, - resolveSymlinks - ), + // Write the tar.gz stream to a temp file to avoid holding the entire + // archive in memory and to guarantee that Content-Length matches the + // body (gzip output is non-deterministic across separate runs). + const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'e2b-upload-')) + const tmpFile = path.join(tmpDir, 'archive.tar.gz') + try { + const writeStream = fs.createWriteStream(tmpFile) + for await (const chunk of stream as unknown as AsyncIterable) { + writeStream.write(chunk) + } + await new Promise((resolve, reject) => { + writeStream.end(() => resolve()) + writeStream.on('error', reject) + }) + + const stat = await fs.promises.stat(tmpFile) + const readStream = fs.createReadStream(tmpFile) + + // Clean up temp file once the read stream is fully consumed or closed + readStream.on('close', () => { + fs.promises.unlink(tmpFile).catch(() => {}) + fs.promises.rmdir(tmpDir).catch(() => {}) + }) + + return { + contentLength: stat.size, + uploadStream: readStream, + } + } catch (err) { + // Clean up on error during temp file creation + await fs.promises.unlink(tmpFile).catch(() => {}) + await fs.promises.rmdir(tmpDir).catch(() => {}) + throw err } }