Skip to content

Commit 90cf91b

Browse files
committed
Build platform tarballs into R2 via multipart, serve from R2 (fix 1102 + truncation)
The build must STREAM (holding the ~48MB decompressed payload to re-tar/re-gzip or to collect it OOMs at Cloudflare 1102) and the result must be served with a Content-Length (a Worker-generated transform stream gets truncated, surfacing as ERR_PNPM_Z_BUF_ERROR). Stream the stored-gzip rewrite into R2 as bounded ~10MB multipart parts, then serve the finished object straight from R2, a plain byte passthrough that cannot be truncated. Bounded memory throughout; cached after the first build.
1 parent b62849e commit 90cf91b

2 files changed

Lines changed: 102 additions & 191 deletions

File tree

src/tarball/getPreviewBuild.ts

Lines changed: 102 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { metaKey, tarballKey } from '../cache/r2Cache'
77
import { tarballCacheControl } from '../cache/headers'
88
import { rewritePackageJson } from './rewritePackageJson'
99
import { assertSafeTarballPath } from '../security/validateTarballPath'
10-
import { rewriteTarEntryInPlace } from './rewriteTarballBuffer'
10+
import { rewriteTarballEntryStream } from './rewriteTarballStream'
1111
import {
1212
buildPreviewTarball,
1313
encodePackageJson,
@@ -16,65 +16,33 @@ import {
1616
type PreviewBuild,
1717
type PreviewMeta,
1818
} from './buildPreviewTarball'
19-
import { fetchUpstreamTarball } from './fetchUpstreamTarball'
19+
import {
20+
fetchUpstreamTarball,
21+
fetchUpstreamTarballStream,
22+
} from './fetchUpstreamTarball'
2023

21-
/**
22-
* Decompress a gzipped tar into a single right-sized buffer. The buffer is
23-
* presized from the gzip ISIZE trailer (decompressed length, valid for the
24-
* <4GB single-member tarballs npm produces) so there is no doubling from a
25-
* growing collector. The compressed input goes out of scope on return.
26-
*/
27-
async function decompressToBuffer(gzipped: Uint8Array): Promise<Uint8Array> {
28-
const isize = new DataView(
29-
gzipped.buffer,
30-
gzipped.byteOffset + gzipped.length - 4,
31-
4,
32-
).getUint32(0, true)
33-
34-
const out = new Uint8Array(isize)
35-
let pos = 0
36-
const reader = new Response(gzipped)
37-
.body!.pipeThrough(new DecompressionStream('gzip'))
38-
.getReader()
39-
try {
40-
for (;;) {
41-
const { done, value } = await reader.read()
42-
if (done) break
43-
out.set(value, pos)
44-
pos += value.byteLength
45-
}
46-
} finally {
47-
reader.releaseLock()
48-
}
49-
return pos === isize ? out : out.subarray(0, pos)
50-
}
24+
// R2 multipart parts must be >=5 MiB and (except the last) equal-sized.
25+
const PART_SIZE = 10 * 1024 * 1024
5126

5227
/**
53-
* Build a large non-preview tarball (a platform binary) as a complete buffer.
54-
*
55-
* Earlier attempts failed two ways: buffering the decompressed payload AND a
56-
* re-tarred copy exceeded the 128MB Worker limit (Cloudflare 1102), while
57-
* streaming the rewrite straight to the client truncated (a strict client
58-
* gunzip then fails with "unexpected end of file"). This decompresses ONCE into
59-
* a single buffer, rewrites `package/package.json` IN PLACE (no second copy of
60-
* the ~48MB binary), then recompresses to a normal-size gzip. Returning a
61-
* complete buffer means the response carries a Content-Length and is never
62-
* truncated. Integrity is not pinned for these packages (see `buildMetaLight`).
28+
* Build the rewritten tarball for a large non-preview package (a platform
29+
* binary) as a stream: swap only `package/package.json` and pass the multi-MB
30+
* native binary straight through, re-emitting gzip "stored" (uncompressed)
31+
* blocks. Nothing is materialized whole, so it stays within the Worker's 128MB
32+
* memory limit (buffering the ~tens-of-MB decompressed payload to re-tar/re-gzip
33+
* it returns Cloudflare 1102).
6334
*/
64-
async function buildPlatformTarball(
35+
async function buildPlatformTarballStream(
6536
env: Env,
6637
name: string,
6738
version: string,
68-
): Promise<Uint8Array> {
39+
): Promise<ReadableStream<Uint8Array>> {
6940
const url = toPkgPrNewUrl(env, name, version)
7041
if (!url) throw new HttpError(400, `Invalid preview version: ${version}`)
7142

72-
const tar = await decompressToBuffer(
73-
await fetchUpstreamTarball(url, maxTarballBytes(env)),
74-
)
75-
76-
rewriteTarEntryInPlace(
77-
tar,
43+
const upstream = await fetchUpstreamTarballStream(url, maxTarballBytes(env))
44+
return rewriteTarballEntryStream(
45+
upstream,
7846
PACKAGE_JSON_NAMES,
7947
(data) => {
8048
let pkg: Record<string, any>
@@ -87,20 +55,80 @@ async function buildPlatformTarball(
8755
},
8856
assertSafeTarballPath,
8957
)
58+
}
9059

91-
const tarball = new Uint8Array(
92-
await new Response(
93-
new Response(tar).body!.pipeThrough(new CompressionStream('gzip')),
94-
).arrayBuffer(),
95-
)
60+
function mergeChunks(chunks: Uint8Array[], total: number): Uint8Array {
61+
if (chunks.length === 1) return chunks[0]
62+
const out = new Uint8Array(total)
63+
let off = 0
64+
for (const c of chunks) {
65+
out.set(c, off)
66+
off += c.byteLength
67+
}
68+
return out
69+
}
9670

97-
await env.TARBALL_CACHE.put(tarballKey(name, version), tarball, {
98-
httpMetadata: {
99-
contentType: 'application/gzip',
100-
cacheControl: tarballCacheControl(),
71+
/**
72+
* Build a platform-binary tarball into R2 via a multipart upload, then leave it
73+
* to be served straight from R2.
74+
*
75+
* This is the only shape that satisfies both constraints: the build STREAMS
76+
* (the stored-gzip consumer keeps pace with the decompressor, so the ~tens-of-MB
77+
* payload is never held whole, which a buffered re-tar/re-gzip would and OOM at
78+
* Cloudflare 1102), and the upload is chunked into bounded ~10MB parts (so it
79+
* never buffers the whole object the way a single put of an unsized stream
80+
* would). Serving the finished object from R2 is a plain byte passthrough with a
81+
* Content-Length, so it cannot be truncated the way a Worker-generated transform
82+
* response can. The work is awaited inside the handler so the Worker pumps it to
83+
* completion. Integrity is not pinned for these packages (see `buildMetaLight`).
84+
*/
85+
async function buildPlatformTarballToR2(
86+
env: Env,
87+
name: string,
88+
version: string,
89+
): Promise<void> {
90+
const stream = await buildPlatformTarballStream(env, name, version)
91+
const upload = await env.TARBALL_CACHE.createMultipartUpload(
92+
tarballKey(name, version),
93+
{
94+
httpMetadata: {
95+
contentType: 'application/gzip',
96+
cacheControl: tarballCacheControl(),
97+
},
10198
},
102-
})
103-
return tarball
99+
)
100+
101+
try {
102+
const parts: R2UploadedPart[] = []
103+
let pending: Uint8Array[] = []
104+
let pendingLen = 0
105+
const reader = stream.getReader()
106+
for (;;) {
107+
const { done, value } = await reader.read()
108+
if (done) break
109+
pending.push(value)
110+
pendingLen += value.byteLength
111+
while (pendingLen >= PART_SIZE) {
112+
const merged = mergeChunks(pending, pendingLen)
113+
parts.push(
114+
await upload.uploadPart(parts.length + 1, merged.subarray(0, PART_SIZE)),
115+
)
116+
const rest = merged.slice(PART_SIZE) // copy so the 10MB buffer is freed
117+
pending = rest.byteLength ? [rest] : []
118+
pendingLen = rest.byteLength
119+
}
120+
}
121+
// The final part may be smaller than PART_SIZE.
122+
if (pendingLen > 0 || parts.length === 0) {
123+
parts.push(
124+
await upload.uploadPart(parts.length + 1, mergeChunks(pending, pendingLen)),
125+
)
126+
}
127+
await upload.complete(parts)
128+
} catch (err) {
129+
await upload.abort()
130+
throw err
131+
}
104132
}
105133

106134
/**
@@ -222,20 +250,23 @@ export async function getPreviewTarballBody(
222250
name: string,
223251
version: string,
224252
): Promise<PreviewTarballBody> {
225-
// Serve a cached build straight from R2: a plain byte passthrough with a
226-
// Content-Length, which streams large objects reliably.
227-
const cached = await env.TARBALL_CACHE.get(tarballKey(name, version))
228-
if (cached) return { body: cached.body, contentLength: cached.size }
229-
230-
// Platform binaries: build as a complete buffer (rewrite package.json in
231-
// place, no second copy of the binary) and return it whole, so the response
232-
// carries a Content-Length and is never truncated.
253+
// Platform binaries: stream the build into R2 first (bounded memory), then
254+
// fall through to serving it from R2 (a passthrough with a Content-Length
255+
// that cannot be truncated). Streaming the transform straight to the client
256+
// truncates, and buffering it OOMs (Cloudflare 1102).
233257
if (!isPreviewPackage(name)) {
234-
const tarball = await buildPlatformTarball(env, name, version)
235-
return { body: tarball, contentLength: tarball.byteLength }
258+
let cached = await env.TARBALL_CACHE.get(tarballKey(name, version))
259+
if (!cached) {
260+
await buildPlatformTarballToR2(env, name, version)
261+
cached = await env.TARBALL_CACHE.get(tarballKey(name, version))
262+
if (!cached) throw new HttpError(500, 'Failed to persist generated tarball')
263+
}
264+
return { body: cached.body, contentLength: cached.size }
236265
}
237266

238267
// Small preview packages keep the buffered+cached+integrity path.
268+
const cached = await env.TARBALL_CACHE.get(tarballKey(name, version))
269+
if (cached) return { body: cached.body, contentLength: cached.size }
239270
const tarball = (await buildAndStore(env, name, version)).tarball
240271
return { body: tarball, contentLength: tarball.byteLength }
241272
}

src/tarball/rewriteTarballBuffer.ts

Lines changed: 0 additions & 120 deletions
This file was deleted.

0 commit comments

Comments
 (0)