From 12ea26c5f9b7a9b108b2e49f23c8e35a18bcf973 Mon Sep 17 00:00:00 2001 From: truffle Date: Wed, 22 Apr 2026 06:13:30 +0000 Subject: [PATCH 1/5] fix(js-sdk): buffer template upload to avoid S3 chunked PUT 501 uploadFile passed a Node Readable directly to fetch, which undici turns into Transfer-Encoding: chunked when Content-Length is unknown. S3 presigned PUT URLs reject chunked requests with 501 NotImplemented, which breaks template uploads against S3-compatible storage in self-hosted deployments (issue #1243). Buffer the archive via stream/consumers.buffer() before PUT so fetch sets Content-Length automatically. Aligns with the Python SDK, which already materializes the tar into io.BytesIO and uploads bytes via httpx. Adds a regression test that spins up a local HTTP server, runs uploadFile against it, and asserts Content-Length is set and matches the received body length. Fixes #1243 --- .../fix-js-sdk-upload-content-length.md | 5 ++ packages/js-sdk/src/template/buildApi.ts | 13 ++-- .../js-sdk/tests/template/uploadFile.test.ts | 72 +++++++++++++++++++ 3 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-js-sdk-upload-content-length.md create mode 100644 packages/js-sdk/tests/template/uploadFile.test.ts diff --git a/.changeset/fix-js-sdk-upload-content-length.md b/.changeset/fix-js-sdk-upload-content-length.md new file mode 100644 index 0000000000..7d4b690059 --- /dev/null +++ b/.changeset/fix-js-sdk-upload-content-length.md @@ -0,0 +1,5 @@ +--- +"e2b": patch +--- + +fix(js-sdk): buffer template tar archive before upload so `fetch` sets `Content-Length` instead of falling back to `Transfer-Encoding: chunked`. S3 presigned PUT URLs reject chunked requests with `501 NotImplemented`, breaking template uploads in self-hosted deployments backed by S3-compatible storage. Aligns the JS SDK with the Python SDK, which already buffers via `io.BytesIO`. diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index d699efe03c..1e51774c05 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -1,3 +1,4 @@ +import { buffer } from 'node:stream/consumers' import { ApiClient, handleApiError, paths, components } from '../api' import { stripAnsi } from '../utils' import { BuildError, FileUploadError, TemplateError } from '../errors' @@ -119,12 +120,16 @@ export async function uploadFile( resolveSymlinks ) - // The compiler assumes this is Web fetch API, but it's actually Node.js fetch API + // Buffer the archive before uploading so fetch sets Content-Length. + // S3 presigned PUT URLs reject Transfer-Encoding: chunked with 501 + // NotImplemented, which is what Node's fetch falls back to when the + // body is a Readable without a known length. See e2b-dev/e2b#1243. + // The Python SDK takes the same approach (build_api.py:upload_file). + const uploadBody = await buffer(uploadStream) + const res = await fetch(url, { method: 'PUT', - // @ts-expect-error - body: uploadStream, - duplex: 'half', + body: uploadBody, }) if (!res.ok) { diff --git a/packages/js-sdk/tests/template/uploadFile.test.ts b/packages/js-sdk/tests/template/uploadFile.test.ts new file mode 100644 index 0000000000..300c8c1bb2 --- /dev/null +++ b/packages/js-sdk/tests/template/uploadFile.test.ts @@ -0,0 +1,72 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest' +import { writeFile, mkdir, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { createServer, type IncomingMessage, type Server } from 'http' +import { AddressInfo } from 'net' +import { uploadFile } from '../../src/template/buildApi' + +// Regression test for e2b-dev/e2b#1243 — uploadFile used to pass a Node +// Readable directly to fetch, which made undici fall back to +// Transfer-Encoding: chunked. S3 presigned PUT URLs reject that with 501 +// NotImplemented. The fix buffers the archive first so Content-Length is set. +describe('uploadFile transfer encoding', () => { + let testDir: string + let server: Server + let baseUrl: string + let capturedHeaders: IncomingMessage['headers'] + let capturedBodyLength: number + + beforeEach(async () => { + testDir = join(tmpdir(), `uploadFile-test-${Date.now()}`) + await mkdir(testDir, { recursive: true }) + await writeFile(join(testDir, 'hello.txt'), 'hello world') + + capturedHeaders = {} + capturedBodyLength = 0 + + server = createServer((req, res) => { + capturedHeaders = req.headers + let bytes = 0 + req.on('data', (chunk: Buffer) => { + bytes += chunk.length + }) + req.on('end', () => { + capturedBodyLength = bytes + res.writeHead(200) + res.end() + }) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const { port } = server.address() as AddressInfo + baseUrl = `http://127.0.0.1:${port}/upload` + }) + + afterEach(async () => { + await new Promise((resolve) => server.close(() => resolve())) + await rm(testDir, { recursive: true, force: true }) + }) + + test('sets Content-Length and does not use chunked transfer encoding', async () => { + await uploadFile( + { + fileName: '*.txt', + fileContextPath: testDir, + url: baseUrl, + ignorePatterns: [], + resolveSymlinks: false, + }, + undefined + ) + + expect(capturedHeaders['content-length']).toBeDefined() + const contentLength = Number(capturedHeaders['content-length']) + expect(contentLength).toBeGreaterThan(0) + expect(contentLength).toBe(capturedBodyLength) + + const transferEncoding = capturedHeaders['transfer-encoding'] + if (transferEncoding !== undefined) { + expect(transferEncoding.toLowerCase()).not.toContain('chunked') + } + }) +}) From fb8f625cc0c49557c80edf738245fe73fcb78c7c Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 22 Apr 2026 12:34:54 +0200 Subject: [PATCH 2/5] test(js-sdk): simplify uploadFile test setup Use mkdtemp for atomic unique temp dir and lift server/fixture lifecycle to beforeAll/afterAll since there's only one test. Co-Authored-By: Claude Opus 4.7 --- .../js-sdk/tests/template/uploadFile.test.ts | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/packages/js-sdk/tests/template/uploadFile.test.ts b/packages/js-sdk/tests/template/uploadFile.test.ts index 300c8c1bb2..3d0cd25b05 100644 --- a/packages/js-sdk/tests/template/uploadFile.test.ts +++ b/packages/js-sdk/tests/template/uploadFile.test.ts @@ -1,5 +1,5 @@ -import { describe, test, expect, beforeEach, afterEach } from 'vitest' -import { writeFile, mkdir, rm } from 'fs/promises' +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { writeFile, mkdtemp, rm } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' import { createServer, type IncomingMessage, type Server } from 'http' @@ -14,17 +14,13 @@ describe('uploadFile transfer encoding', () => { let testDir: string let server: Server let baseUrl: string - let capturedHeaders: IncomingMessage['headers'] - let capturedBodyLength: number + let capturedHeaders: IncomingMessage['headers'] = {} + let capturedBodyLength = 0 - beforeEach(async () => { - testDir = join(tmpdir(), `uploadFile-test-${Date.now()}`) - await mkdir(testDir, { recursive: true }) + beforeAll(async () => { + testDir = await mkdtemp(join(tmpdir(), 'uploadFile-test-')) await writeFile(join(testDir, 'hello.txt'), 'hello world') - capturedHeaders = {} - capturedBodyLength = 0 - server = createServer((req, res) => { capturedHeaders = req.headers let bytes = 0 @@ -42,7 +38,7 @@ describe('uploadFile transfer encoding', () => { baseUrl = `http://127.0.0.1:${port}/upload` }) - afterEach(async () => { + afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())) await rm(testDir, { recursive: true, force: true }) }) From 9d9011b8f692a70e17b94526d1abbd69cb6069e3 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 22 Apr 2026 14:46:24 +0200 Subject: [PATCH 3/5] fix(js-sdk): cast tar Pack to AsyncIterable for buffer() The cli's typecheck (without skipLibCheck on the js-sdk source it imports via path mapping) didn't narrow tar's Pack to the AsyncIterable overload of node:stream/consumers' buffer(). Cast via unknown to satisfy both the cli and js-sdk typechecks. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/template/buildApi.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index 1e51774c05..5a9be46e5d 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -125,7 +125,12 @@ export async function uploadFile( // NotImplemented, which is what Node's fetch falls back to when the // body is a Readable without a known length. See e2b-dev/e2b#1243. // The Python SDK takes the same approach (build_api.py:upload_file). - const uploadBody = await buffer(uploadStream) + // tar's Pack extends Minipass and is iterable as AsyncIterable at + // runtime, but the cli's tsconfig (preserveSymlinks) doesn't surface that + // through the type chain — cast via unknown. + const uploadBody = await buffer( + uploadStream as unknown as AsyncIterable + ) const res = await fetch(url, { method: 'PUT', From 384cac2ea216923e38931b3dcccd87d56b100c96 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:53:00 +0200 Subject: [PATCH 4/5] fix(js-sdk): dynamically import node:stream/consumers in uploadFile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static import broke the browser build — vite externalized the module and threw at import time for browser test files that transitively pulled in buildApi. Defer the import inside uploadFile like tarFileStream already does for the tar package. Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/template/buildApi.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index 5a9be46e5d..4b91419ac5 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -1,6 +1,5 @@ -import { buffer } from 'node:stream/consumers' import { ApiClient, handleApiError, paths, components } from '../api' -import { stripAnsi } from '../utils' +import { dynamicImport, stripAnsi } from '../utils' import { BuildError, FileUploadError, TemplateError } from '../errors' import { LogEntry } from './logger' import { getBuildStepIndex, tarFileStreamUpload } from './utils' @@ -125,9 +124,13 @@ export async function uploadFile( // NotImplemented, which is what Node's fetch falls back to when the // body is a Readable without a known length. See e2b-dev/e2b#1243. // The Python SDK takes the same approach (build_api.py:upload_file). + // Dynamically import so the browser bundle doesn't pull in node:stream. // tar's Pack extends Minipass and is iterable as AsyncIterable at // runtime, but the cli's tsconfig (preserveSymlinks) doesn't surface that // through the type chain — cast via unknown. + const { buffer } = await dynamicImport( + 'node:stream/consumers' + ) const uploadBody = await buffer( uploadStream as unknown as AsyncIterable ) From ded261327189114f2571555e642a8a4c7f4f30fb Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:56:58 +0200 Subject: [PATCH 5/5] style(js-sdk): apply prettier formatting Co-Authored-By: Claude Opus 4.7 --- packages/js-sdk/src/template/buildApi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/js-sdk/src/template/buildApi.ts b/packages/js-sdk/src/template/buildApi.ts index 4b91419ac5..b070d2bbb1 100644 --- a/packages/js-sdk/src/template/buildApi.ts +++ b/packages/js-sdk/src/template/buildApi.ts @@ -128,9 +128,9 @@ export async function uploadFile( // tar's Pack extends Minipass and is iterable as AsyncIterable at // runtime, but the cli's tsconfig (preserveSymlinks) doesn't surface that // through the type chain — cast via unknown. - const { buffer } = await dynamicImport( - 'node:stream/consumers' - ) + const { buffer } = await dynamicImport< + typeof import('node:stream/consumers') + >('node:stream/consumers') const uploadBody = await buffer( uploadStream as unknown as AsyncIterable )