@@ -7,7 +7,7 @@ import { metaKey, tarballKey } from '../cache/r2Cache'
77import { tarballCacheControl } from '../cache/headers'
88import { rewritePackageJson } from './rewritePackageJson'
99import { assertSafeTarballPath } from '../security/validateTarballPath'
10- import { rewriteTarEntryInPlace } from './rewriteTarballBuffer '
10+ import { rewriteTarballEntryStream } from './rewriteTarballStream '
1111import {
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}
0 commit comments