@@ -17,6 +17,7 @@ import {
1717 RegistryError ,
1818 UploadId ,
1919 UploadObject ,
20+ BlobRangeRequest ,
2021} from "./registry" ;
2122import { ociImageIndexContentType } from "./r2" ;
2223
@@ -156,18 +157,41 @@ function ctxIntoHeaders(ctx: HTTPContext): Headers {
156157 return headers ;
157158}
158159
159- function ctxIntoRequest ( ctx : HTTPContext , url : URL , method : string , path : string , body ?: BodyInit ) : Request {
160+ function ctxIntoRequest (
161+ ctx : HTTPContext ,
162+ url : URL ,
163+ method : string ,
164+ path : string ,
165+ body ?: BodyInit ,
166+ extraHeaders ?: HeadersInit ,
167+ ) : Request {
160168 const urlReq = `${ url . protocol } //${ url . host } /v2${
161169 ctx . repository === "" || ctx . repository === "/" ? "/" : ctx . repository + "/"
162170 } ${ path } `;
171+ const headers = ctxIntoHeaders ( ctx ) ;
172+ if ( extraHeaders !== undefined ) {
173+ new Headers ( extraHeaders ) . forEach ( ( value , key ) => headers . set ( key , value ) ) ;
174+ }
163175 return new Request ( urlReq , {
164176 method,
165177 body,
166178 redirect : "follow" ,
167- headers : ctxIntoHeaders ( ctx ) ,
179+ headers,
168180 } ) ;
169181}
170182
183+ // Parses an HTTP "Content-Range: bytes <start>-<end>/<size>" response header.
184+ function parseContentRange ( header : string | null ) : { start : number ; end : number ; size : number } | null {
185+ if ( header === null ) return null ;
186+ const match = / ^ b y t e s ( \d + ) - ( \d + ) \/ ( \d + ) $ / . exec ( header . trim ( ) ) ;
187+ if ( match === null ) return null ;
188+ const start = Number ( match [ 1 ] ) ;
189+ const end = Number ( match [ 2 ] ) ;
190+ const size = Number ( match [ 3 ] ) ;
191+ if ( ! Number . isInteger ( start ) || ! Number . isInteger ( end ) || ! Number . isInteger ( size ) ) return null ;
192+ return { start, end, size } ;
193+ }
194+
171195function authHeaderIntoAuthContext ( urlObject : URL , authenticateHeader : string ) : AuthContext {
172196 const url = urlObject . toString ( ) ;
173197 const parts = authenticateHeader . split ( " " ) ;
@@ -519,18 +543,29 @@ export class RegistryHTTPClient implements Registry {
519543 }
520544 }
521545
522- async getLayer ( name : string , digest : string ) : Promise < GetLayerResponse | RegistryError > {
546+ async getLayer ( name : string , digest : string , range ?: BlobRangeRequest ) : Promise < GetLayerResponse | RegistryError > {
523547 const namespace = name . includes ( "/" ) || ! isDockerDotIO ( this . url ) ? name : `library/${ name } ` ;
524548 try {
525549 const ctx = await this . authenticate ( namespace ) ;
526- const req = ctxIntoRequest ( ctx , this . url , "GET" , `${ namespace } /blobs/${ digest } ` ) ;
550+ const rangeHeader =
551+ range === undefined ? undefined : `bytes=${ range . offset } -${ range . end === undefined ? "" : range . end } ` ;
552+ const req = ctxIntoRequest (
553+ ctx ,
554+ this . url ,
555+ "GET" ,
556+ `${ namespace } /blobs/${ digest } ` ,
557+ undefined ,
558+ rangeHeader !== undefined ? { Range : rangeHeader } : undefined ,
559+ ) ;
527560 let res = await fetch ( req ) ;
528561 if ( ! res . ok ) {
529562 // This means we got a redirect, so let's try again this URL but
530563 // without any headers. Services like S3 reject authorization headers altogether
531564 // if the authentication is included in the URL.
532565 if ( res . url !== req . url ) {
533- const redirectResponse = await fetch ( new Request ( res . url ) ) ;
566+ const redirectResponse = await fetch (
567+ new Request ( res . url , rangeHeader !== undefined ? { headers : { Range : rangeHeader } } : undefined ) ,
568+ ) ;
534569 if ( ! redirectResponse . ok ) {
535570 return {
536571 response : res ,
@@ -549,11 +584,27 @@ export class RegistryHTTPClient implements Registry {
549584 throw new Error ( "returned body is null" ) ;
550585 }
551586
552- return {
587+ const layer : GetLayerResponse = {
553588 stream : res . body ,
554589 size : + ( res . headers . get ( "Content-Length" ) ?? "0" ) ,
555590 digest : res . headers . get ( "Digest-Content-Digest" ) ?? digest ,
556591 } ;
592+
593+ // If we asked for a range and the upstream honored it, surface the partial-content metadata so
594+ // the caller can reply with 206. A 200 here means the upstream ignored the range and we serve
595+ // the full blob (best-effort). Serving a partial body as if it were complete would corrupt it,
596+ // so a 206 without a parseable Content-Range is treated as an error.
597+ if ( range !== undefined && res . status === 206 ) {
598+ const contentRange = parseContentRange ( res . headers . get ( "Content-Range" ) ) ;
599+ if ( contentRange === null ) {
600+ throw new Error ( "upstream returned 206 without a parseable Content-Range header" ) ;
601+ }
602+
603+ layer . size = contentRange . size ;
604+ layer . contentRange = contentRange ;
605+ }
606+
607+ return layer ;
557608 } catch ( err ) {
558609 console . error ( `Error doing get layer with ${ namespace } and ${ digest } : ` + errorString ( err ) ) ;
559610 return {
0 commit comments