@@ -15,7 +15,7 @@ import {
1515 RandomAccessReader ,
1616 type ZipFile ,
1717} from 'yauzl' ;
18- import { inflateRawSync } from 'zlib' ;
18+ import { inflateRawSync , constants as zlibConstants } from 'zlib' ;
1919import { webFetch } from './runtime' ;
2020import { readEntry } from './zip-entries' ;
2121
@@ -33,6 +33,11 @@ const TAIL_BYTES = 22 + 0xffff + 20;
3333const CHUNK_BYTES = 64 * 1024 ;
3434/** extra bytes past a hinted entry, covering a longer local extra field */
3535const HINT_SLACK_BYTES = 4 * 1024 ;
36+ /**
37+ * compressed bytes fetched to decode the head of a deflated entry: the
38+ * dynamic Huffman header plus the first literals fit comfortably
39+ */
40+ const HEAD_PROBE_BYTES = 4 * 1024 ;
3641
3742export interface ZipEntryLocation {
3843 fileName : string ;
@@ -199,10 +204,11 @@ function timeoutFor(bytes: number, options: RangeOptions): number {
199204 if ( bytes <= CHUNK_BYTES ) {
200205 return options . headerTimeoutMs ?? DEFAULT_HEADER_TIMEOUT_MS ;
201206 }
202- return (
207+ // AbortSignal.timeout() rejects a fractional delay
208+ return Math . ceil (
203209 ( options . dataTimeoutMs ?? DEFAULT_DATA_TIMEOUT_MS ) +
204- ( ( options . dataTimeoutPerMbMs ?? DEFAULT_DATA_TIMEOUT_PER_MB_MS ) * bytes ) /
205- ( 1024 * 1024 )
210+ ( ( options . dataTimeoutPerMbMs ?? DEFAULT_DATA_TIMEOUT_PER_MB_MS ) * bytes ) /
211+ ( 1024 * 1024 ) ,
206212 ) ;
207213}
208214
@@ -360,6 +366,65 @@ export async function fetchZipEntryData(
360366 } ;
361367}
362368
369+ /**
370+ * Up to `bytes` leading bytes of an entry, decoded from a prefix of its
371+ * compressed data (a truncated deflate stream is inflated as far as it goes).
372+ */
373+ export function decodeEntryHead (
374+ raw : Buffer ,
375+ compressionMethod : number ,
376+ bytes : number ,
377+ ) : Buffer {
378+ if ( compressionMethod === ZIP_STORED ) return raw . subarray ( 0 , bytes ) ;
379+ if ( compressionMethod !== ZIP_DEFLATED ) {
380+ throw new Error ( `unsupported compression method ${ compressionMethod } ` ) ;
381+ }
382+ return inflateRawSync ( raw , {
383+ finishFlush : zlibConstants . Z_SYNC_FLUSH ,
384+ } ) . subarray ( 0 , bytes ) ;
385+ }
386+
387+ /** compressed bytes needed to decode the first `bytes` of an entry */
388+ function headProbeSize (
389+ compressedSize : number ,
390+ compressionMethod : number ,
391+ bytes : number ,
392+ ) : number {
393+ return Math . min (
394+ compressedSize ,
395+ compressionMethod === ZIP_STORED ? bytes : HEAD_PROBE_BYTES ,
396+ ) ;
397+ }
398+
399+ /**
400+ * The first `bytes` of an entry whose compressed bytes are already located,
401+ * from one small Range request (see `fetchZipEntryData`).
402+ */
403+ export async function fetchZipEntryHead (
404+ url : string ,
405+ location : Pick <
406+ ZipEntryLocation ,
407+ 'dataOffset' | 'compressedSize' | 'compressionMethod'
408+ > ,
409+ bytes : number ,
410+ options : RangeOptions = { } ,
411+ ) : Promise < Buffer > {
412+ const { dataOffset, compressedSize, compressionMethod } = location ;
413+ if (
414+ ! Number . isInteger ( dataOffset ) ||
415+ ! Number . isInteger ( compressedSize ) ||
416+ dataOffset < 0 ||
417+ compressedSize <= 0
418+ ) {
419+ throw new Error ( 'invalid bundle location' ) ;
420+ }
421+ const size = headProbeSize ( compressedSize , compressionMethod , bytes ) ;
422+ const { data } = await fetchRangeBuffer ( url , dataOffset , dataOffset + size , {
423+ timeoutMs : timeoutFor ( size , options ) ,
424+ } ) ;
425+ return decodeEntryHead ( data , compressionMethod , bytes ) ;
426+ }
427+
363428export interface HttpRangeReaderOptions extends RangeOptions {
364429 /** validator of the archive (see `rangeValidator`), sent as If-Range */
365430 ifRange ?: string ;
@@ -693,14 +758,67 @@ export async function openRemoteZip(
693758 return { kind : 'zip' , zipFile, reader } ;
694759}
695760
761+ /** `reader.read` as a promise */
762+ function readAt (
763+ reader : HttpRangeReader ,
764+ position : number ,
765+ length : number ,
766+ ) : Promise < Buffer > {
767+ const buffer = Buffer . alloc ( length ) ;
768+ return new Promise ( ( resolve , reject ) =>
769+ reader . read ( buffer , 0 , length , position , ( error ) =>
770+ error ? reject ( error ) : resolve ( buffer ) ,
771+ ) ,
772+ ) ;
773+ }
774+
775+ /** The first `bytes` of `entry`, read through `reader` without its body. */
776+ async function remoteEntryHead (
777+ reader : HttpRangeReader ,
778+ entry : Entry ,
779+ bytes : number ,
780+ ) : Promise < Buffer > {
781+ const start = entry . relativeOffsetOfLocalHeader ;
782+ const header = await readAt ( reader , start , LOCAL_HEADER_SIZE ) ;
783+ if ( header . readUInt32LE ( 0 ) !== LOCAL_HEADER_SIGNATURE ) {
784+ throw new Error ( 'invalid local file header signature' ) ;
785+ }
786+ const dataOffset =
787+ start +
788+ LOCAL_HEADER_SIZE +
789+ header . readUInt16LE ( 26 ) +
790+ header . readUInt16LE ( 28 ) ;
791+ const size = headProbeSize (
792+ entry . compressedSize ,
793+ entry . compressionMethod ,
794+ bytes ,
795+ ) ;
796+ if ( size <= 0 ) return Buffer . alloc ( 0 ) ;
797+ return decodeEntryHead (
798+ await readAt ( reader , dataOffset , size ) ,
799+ entry . compressionMethod ,
800+ bytes ,
801+ ) ;
802+ }
803+
804+ /** Look at the head of the matched entry before its body is fetched. */
805+ export interface EntryHeadCheck {
806+ bytes : number ;
807+ /** throw to abandon the entry; the error is passed on as is */
808+ check : ( head : Buffer ) => void ;
809+ }
810+
696811/**
697812 * Read the first matching entry of an opened remote zip, then close it. With
698- * `reader` given, the entry's header and data are fetched in one request.
813+ * `reader` given, the entry's header and data are fetched in one request —
814+ * or, with `head` too, its first bytes are checked first (header and head
815+ * share one small request, the rest of the body follows only if they pass).
699816 */
700817export function readRemoteZipEntry (
701818 zipFile : ZipFile ,
702819 matches : ( name : string ) => boolean ,
703820 reader ?: HttpRangeReader ,
821+ head ?: EntryHeadCheck ,
704822) : Promise < Buffer | null > {
705823 return new Promise ( ( resolve , reject ) => {
706824 let settled = false ;
@@ -717,11 +835,17 @@ export function readRemoteZipEntry(
717835 zipFile . readEntry ( ) ;
718836 return ;
719837 }
720- reader ?. hintEntry ( entry ) ;
721- readEntry ( entry , zipFile ) . then (
722- ( data ) => finish ( null , data ) ,
723- ( error ) => finish ( error ) ,
724- ) ;
838+ const probe =
839+ reader && head
840+ ? remoteEntryHead ( reader , entry , head . bytes ) . then ( head . check )
841+ : // without a probe the header read may take the body along
842+ Promise . resolve ( reader ?. hintEntry ( entry ) ) ;
843+ probe
844+ . then ( ( ) => readEntry ( entry , zipFile ) )
845+ . then (
846+ ( data ) => finish ( null , data ) ,
847+ ( error ) => finish ( error ) ,
848+ ) ;
725849 } ) ;
726850 zipFile . readEntry ( ) ;
727851 } ) ;
0 commit comments