Skip to content

Commit 2b7dd17

Browse files
mishushakovclaude
andauthored
feat(sdk): add gzip option to template copy layer (#1482)
Adds a `gzip` option to the template `.copy()` / `copyItems` layer that controls whether copied files are gzipped before upload, threaded from the copy call through the build-time tar stream in both the JS SDK and the sync/async Python SDKs. It is enabled by default to preserve existing behavior, so passing `gzip: false` (`gzip=False`) uploads an uncompressed tar — useful for already-compressed payloads where gzip adds CPU cost without shrinking the upload. The option name matches node-tar's own `gzip` option and the existing sandbox filesystem `gzip` kwarg. Gzip is deliberately excluded from the file cache hash, so toggling it does not bust the build cache. Tests in both SDKs were updated for the new argument and extended with `gzip: false` cases asserting the archive is not gzipped yet still extracts, and a changeset (`minor` for both packages) is included. > [!NOTE] > The server that extracts these uploaded archives lives in another repo and must auto-detect compression (peek the gzip `0x1f 0x8b` magic) rather than assuming gzip; confirm it handles plain tars before release. ## Usage ```ts // JS/TS template.copy('model.bin', '/app/', { gzip: false }) template.copyItems([{ src: 'a.bin', dest: '/app/', gzip: false }]) ``` ```python # Python (sync & async) template.copy('model.bin', '/app/', gzip=False) template.copy_items([{ 'src': 'a.bin', 'dest': '/app/', 'gzip': False }]) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a39db3b commit 2b7dd17

20 files changed

Lines changed: 173 additions & 24 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"e2b": minor
3+
"@e2b/python-sdk": minor
4+
---
5+
6+
Add a `gzip` option to the template `copy` layer to control whether copied
7+
files are gzipped before upload.
8+
9+
Gzip is enabled by default (matching the previous behavior). Pass
10+
`gzip: false` (`gzip=False` in Python) to upload an uncompressed tar archive
11+
instead — useful when copying already-compressed files where gzipping adds
12+
CPU cost without shrinking the payload.
13+
14+
```ts
15+
// JS/TS
16+
template.copy('model.bin', '/app/', { gzip: false })
17+
```
18+
19+
```python
20+
# Python
21+
template.copy('model.bin', '/app/', gzip=False)
22+
```

packages/js-sdk/src/template/buildApi.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ export async function uploadFile(
111111
url: string
112112
ignorePatterns: string[]
113113
resolveSymlinks: boolean
114+
gzip: boolean
114115
},
115116
stackTrace: string | undefined,
116117
// Uploads (PUT to S3 presigned URL) can take a long time for large
@@ -120,8 +121,14 @@ export async function uploadFile(
120121
// via `signal`) to override.
121122
abortOpts?: { signal?: AbortSignal; requestTimeoutMs?: number }
122123
) {
123-
const { fileName, url, fileContextPath, ignorePatterns, resolveSymlinks } =
124-
options
124+
const {
125+
fileName,
126+
url,
127+
fileContextPath,
128+
ignorePatterns,
129+
resolveSymlinks,
130+
gzip,
131+
} = options
125132
// Spool the archive to a temporary file and stream it from disk instead of
126133
// buffering in memory. S3 presigned PUT URLs reject Transfer-Encoding:
127134
// chunked with 501 NotImplemented (see e2b-dev/e2b#1243), so the upload
@@ -139,7 +146,8 @@ export async function uploadFile(
139146
fileName,
140147
fileContextPath,
141148
ignorePatterns,
142-
resolveSymlinks
149+
resolveSymlinks,
150+
gzip
143151
)
144152
uploadStream = tar.stream
145153

packages/js-sdk/src/template/consts.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export const STACK_TRACE_DEPTH = 3
3333
*/
3434
export const RESOLVE_SYMLINKS = false
3535

36+
/**
37+
* Default setting for whether to gzip files when copying them into the
38+
* template. When true, the upload archive is gzipped before being uploaded.
39+
* @internal
40+
*/
41+
export const GZIP = true
42+
3643
/**
3744
* Default per-request timeout (in milliseconds) for the file-upload phase
3845
* (PUT to S3 presigned URL) when the caller hasn't supplied

packages/js-sdk/src/template/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
uploadFile,
1717
waitForBuildFinish,
1818
} from './buildApi'
19-
import { RESOLVE_SYMLINKS, STACK_TRACE_DEPTH } from './consts'
19+
import { GZIP, RESOLVE_SYMLINKS, STACK_TRACE_DEPTH } from './consts'
2020
import { parseDockerfile } from './dockerfileParser'
2121
import { LogEntry, LogEntryEnd, LogEntryStart } from './logger'
2222
import { ReadyCmd, waitForFile } from './readycmd'
@@ -570,6 +570,7 @@ export class TemplateBase
570570
user?: string
571571
mode?: number
572572
resolveSymlinks?: boolean
573+
gzip?: boolean
573574
}
574575
): TemplateBuilder {
575576
if (runtime === 'browser') {
@@ -598,6 +599,7 @@ export class TemplateBase
598599
force: options?.forceUpload || this.forceNextLayer,
599600
forceUpload: options?.forceUpload,
600601
resolveSymlinks: options?.resolveSymlinks,
602+
gzip: options?.gzip,
601603
})
602604

603605
// Collect one stack trace per pushed instruction so build steps stay
@@ -626,6 +628,7 @@ export class TemplateBase
626628
user: item.user,
627629
mode: item.mode,
628630
resolveSymlinks: item.resolveSymlinks,
631+
gzip: item.gzip,
629632
})
630633
} catch (error) {
631634
const copyError = error as Error
@@ -1193,6 +1196,7 @@ export class TemplateBase
11931196
...readDockerignore(this.fileContextPath.toString()),
11941197
],
11951198
resolveSymlinks: instruction.resolveSymlinks ?? RESOLVE_SYMLINKS,
1199+
gzip: instruction.gzip ?? GZIP,
11961200
},
11971201
stackTrace,
11981202
// Forward `requestTimeoutMs` only when the caller set it — we

packages/js-sdk/src/template/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ export type Instruction = {
197197
forceUpload?: true
198198
filesHash?: string
199199
resolveSymlinks?: boolean
200+
gzip?: boolean
200201
}
201202

202203
/**
@@ -209,6 +210,7 @@ export type CopyItem = {
209210
user?: string
210211
mode?: number
211212
resolveSymlinks?: boolean
213+
gzip?: boolean
212214
}
213215

214216
/**
@@ -412,6 +414,7 @@ export interface TemplateBuilder {
412414
user?: string
413415
mode?: number
414416
resolveSymlinks?: boolean
417+
gzip?: boolean
415418
}
416419
): TemplateBuilder
417420

packages/js-sdk/src/template/utils.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -371,13 +371,15 @@ export function padOctal(mode: number): string {
371371
* @param fileContextPath Base directory for resolving file paths
372372
* @param ignorePatterns Ignore patterns to exclude from the archive
373373
* @param resolveSymlinks Whether to follow symbolic links
374+
* @param gzip Whether to gzip the archive
374375
* @returns The readable stream and the archive size in bytes
375376
*/
376377
export async function tarFileStream(
377378
fileName: string,
378379
fileContextPath: string,
379380
ignorePatterns: string[],
380-
resolveSymlinks: boolean
381+
resolveSymlinks: boolean,
382+
gzip: boolean
381383
): Promise<{ stream: Readable; size: number }> {
382384
const { create } = await dynamicImport<typeof import('tar')>('tar')
383385
// Dynamically import so the browser bundle doesn't pull in node:fs.
@@ -405,7 +407,7 @@ export async function tarFileStream(
405407
try {
406408
await create(
407409
{
408-
gzip: true,
410+
gzip,
409411
cwd: fileContextPath,
410412
follow: resolveSymlinks,
411413
noDirRecurse: true,

packages/js-sdk/tests/template/uploadFile.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('uploadFile transfer encoding', () => {
5252
url: baseUrl,
5353
ignorePatterns: [],
5454
resolveSymlinks: false,
55+
gzip: true,
5556
},
5657
undefined
5758
)

packages/js-sdk/tests/template/utils/tarFileStream.test.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,13 @@ describe('tarFileStream', () => {
126126
await writeFile(join(testDir, 'file1.txt'), 'content1')
127127
await writeFile(join(testDir, 'file2.txt'), 'content2')
128128

129-
const { stream, size } = await tarFileStream('*.txt', testDir, [], false)
129+
const { stream, size } = await tarFileStream(
130+
'*.txt',
131+
testDir,
132+
[],
133+
false,
134+
true
135+
)
130136
expect(size).toBeGreaterThan(0)
131137

132138
const contents = await extractTarContents(stream)
@@ -136,6 +142,39 @@ describe('tarFileStream', () => {
136142
expect(contents.get('file2.txt')?.toString()).toBe('content2')
137143
})
138144

145+
test('should create an uncompressed tar when gzip is disabled', async () => {
146+
await writeFile(join(testDir, 'file1.txt'), 'content1')
147+
await writeFile(join(testDir, 'file2.txt'), 'content2')
148+
149+
const { stream } = await tarFileStream('*.txt', testDir, [], false, false)
150+
151+
// Buffer the archive so we can both assert it is not gzipped and extract it
152+
const chunks: Buffer[] = []
153+
for await (const chunk of stream as unknown as AsyncIterable<Buffer>) {
154+
chunks.push(Buffer.from(chunk))
155+
}
156+
const archive = Buffer.concat(chunks)
157+
158+
// gzip streams start with the magic bytes 0x1f 0x8b — an uncompressed tar must not
159+
expect(archive[0]).not.toBe(0x1f)
160+
expect(archive.subarray(0, 2).equals(Buffer.from([0x1f, 0x8b]))).toBe(false)
161+
162+
// And it should still extract to the original files
163+
const tarFile = join(tmpdir(), `tar-uncompressed-${Date.now()}.tar`)
164+
await writeFile(tarFile, archive)
165+
const extractDir = join(tmpdir(), `tar-uncompressed-extract-${Date.now()}`)
166+
await mkdir(extractDir, { recursive: true })
167+
await tar.extract({ file: tarFile, cwd: extractDir })
168+
expect((await readFile(join(extractDir, 'file1.txt'))).toString()).toBe(
169+
'content1'
170+
)
171+
expect((await readFile(join(extractDir, 'file2.txt'))).toString()).toBe(
172+
'content2'
173+
)
174+
await rm(tarFile, { force: true })
175+
await rm(extractDir, { recursive: true, force: true })
176+
})
177+
139178
test('should respect ignore patterns', async () => {
140179
await writeFile(join(testDir, 'file1.txt'), 'content1')
141180
await writeFile(join(testDir, 'file2.txt'), 'content2')
@@ -146,7 +185,8 @@ describe('tarFileStream', () => {
146185
'*.txt',
147186
testDir,
148187
['temp*', 'backup*'],
149-
false
188+
false,
189+
true
150190
)
151191

152192
const contents = await extractTarContents(stream)
@@ -165,7 +205,7 @@ describe('tarFileStream', () => {
165205
await writeFile(join(testDir, 'src', 'index.ts'), 'index content')
166206
await writeFile(join(nestedDir, 'Button.tsx'), 'button content')
167207

168-
const { stream } = await tarFileStream('src', testDir, [], false)
208+
const { stream } = await tarFileStream('src', testDir, [], false, true)
169209

170210
const contents = await extractTarContents(stream)
171211
const paths = Array.from(contents.keys())
@@ -187,7 +227,8 @@ describe('tarFileStream', () => {
187227
throw error
188228
}
189229

190-
const { stream } = await tarFileStream('*.txt', testDir, [], true)
230+
// Test with resolveSymlinks=true
231+
const { stream } = await tarFileStream('*.txt', testDir, [], true, true)
191232

192233
const contents = await extractTarContents(stream)
193234
expect(contents.get('original.txt')?.toString()).toBe('original content')
@@ -208,7 +249,8 @@ describe('tarFileStream', () => {
208249
throw error
209250
}
210251

211-
const { stream } = await tarFileStream('*.txt', testDir, [], false)
252+
// Test with resolveSymlinks=false
253+
const { stream } = await tarFileStream('*.txt', testDir, [], false, true)
212254

213255
const members = await getTarMembers(stream)
214256
expect(members.get('original.txt')?.type).toBe('File')

packages/python-sdk/e2b/template/consts.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
"""
3030
RESOLVE_SYMLINKS = False
3131

32+
"""
33+
Default setting for whether to gzip files when copying them into the
34+
template. When True, the upload archive is gzipped before being uploaded.
35+
"""
36+
GZIP = True
37+
3238
"""
3339
Default timeout (in seconds) for uploading the build-context archive to the
3440
S3 presigned URL. Uploads of large archives can take far longer than the 60s

packages/python-sdk/e2b/template/dockerfile_parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ def copy(
2828
user: Optional[str] = None,
2929
mode: Optional[int] = None,
3030
resolve_symlinks: Optional[bool] = None,
31+
gzip: Optional[bool] = None,
3132
) -> "DockerfileParserInterface":
3233
"""Handle COPY instruction."""
3334
...

0 commit comments

Comments
 (0)