@@ -161,9 +161,14 @@ function calculateRequestSize(
161161}
162162
163163/**
164- * Calculates the exact response byte size for the HTTP request by making the request itself and counting the response
164+ * Calculates the response byte size for the HTTP request by making the request itself and counting the response.
165165 *
166- * @param url Full request uRL including protocol, domain, path
166+ * Fallback priority when content-length is absent (i.e., Transfer Encoding is chunked):
167+ * 1. Resource Timing API (encodedBodySize) — on-wire size, but may be 0 for cross-origin without TAO
168+ * 2. Re-compress with gzip via CompressionStream — close estimate when the response was gzip-encoded
169+ * 3. Decompressed body length — last resort, will overshoot for compressed responses
170+ *
171+ * @param url Full request URL including protocol, domain, path
167172 * @param method HTTP method (ie. "GET")
168173 * @param headers HTTP request headers
169174 * @param body Optional request body
@@ -196,17 +201,86 @@ async function calculateResponseSize(
196201
197202 const contentLength = response . headers . get ( 'content-length' ) ;
198203
199- let bodySize : number ;
200-
201204 if ( contentLength ) {
202- bodySize = parseInt ( contentLength , 10 ) ;
203- } else {
204- console . debug ( 'fallback to measuring response blob due to no content-length header' ) ;
205- const blob = await response . blob ( ) ;
206- bodySize = blob . size ;
205+ return headersSize + parseInt ( contentLength , 10 ) ;
206+ }
207+
208+ // No content-length; consume the body so we can estimate the on-wire size
209+ const decompressedBody = await response . arrayBuffer ( ) ;
210+ const contentEncoding = response . headers . get ( 'content-encoding' ) ;
211+ const wasGzipped = contentEncoding ?. toLowerCase ( ) . includes ( 'gzip' ) ?? false ;
212+
213+ // Try Resource Timing API for the encoded (on-wire) body size.
214+ // Returns 0 for cross-origin responses without Timing-Allow-Origin,
215+ // but worth checking since it's the most accurate when available.
216+ const perfBodySize = await getEncodedBodySizeFromPerformance ( url ) ;
217+ if ( perfBodySize > 0 ) {
218+ // encodedBodySize excludes chunked transfer encoding framing (chunk-size
219+ // hex digits + CRLFs per chunk), which IS counted by the notary. Pad to cover it.
220+ const adjustedPerfBodySize = Math . ceil ( perfBodySize * 1.05 ) + 256 ;
221+ console . debug ( 'fallback to performance body size:' , adjustedPerfBodySize ) ;
222+
223+ return headersSize + adjustedPerfBodySize ;
224+ }
225+
226+ // Re-compress with gzip to approximate the on-wire body size.
227+ // Only meaningful when the server actually sent gzip'd data.
228+ if ( wasGzipped && typeof CompressionStream !== 'undefined' ) {
229+ const compressedSize = await estimateGzipSize ( new Uint8Array ( decompressedBody ) ) ;
230+ // CompressionStream uses zlib default (level 6), but the server may use a
231+ // lower level (larger output). Pad generously — overshooting is safe,
232+ // undershooting causes a protocol failure.
233+ const adjustedCompressedSize = Math . ceil ( compressedSize * 1.15 ) + 256 ;
234+ console . debug ( 'fallback to estimated gzip size:' , adjustedCompressedSize ) ;
235+
236+ return headersSize + adjustedCompressedSize ;
237+ }
238+
239+ // Last resort: decompressed body length (overshoots for compressed responses)
240+ console . debug ( 'fallback to decompressed body size — on-wire size may differ' ) ;
241+ return headersSize + decompressedBody . byteLength ;
242+ }
243+
244+ /**
245+ * Attempts to read the encoded (on-wire) body size from the Resource Timing API.
246+ * Returns 0 if unavailable or if the entry reports 0 (e.g. cross-origin without TAO).
247+ */
248+ async function getEncodedBodySizeFromPerformance ( url : string ) : Promise < number > {
249+ if ( typeof performance === 'undefined' || ! performance . getEntriesByName ) {
250+ return 0 ;
251+ }
252+
253+ // Yield to the event loop so the browser has a chance to queue the entry
254+ await new Promise < void > ( ( r ) => setTimeout ( r , 0 ) ) ;
255+
256+ const entries = performance . getEntriesByName ( url , 'resource' ) as PerformanceResourceTiming [ ] ;
257+ if ( entries . length === 0 ) {
258+ return 0 ;
259+ }
260+
261+ return entries [ entries . length - 1 ] . encodedBodySize ?? 0 ;
262+ }
263+
264+ /**
265+ * Compresses data with gzip using the Compression Streams API and returns the resulting size.
266+ * Available in Chrome 80+ and Firefox 113+.
267+ */
268+ async function estimateGzipSize ( data : Uint8Array ) : Promise < number > {
269+ const cs = new CompressionStream ( 'gzip' ) ;
270+ const writer = cs . writable . getWriter ( ) ;
271+ const reader = cs . readable . getReader ( ) ;
272+
273+ writer . write ( data as BufferSource ) ;
274+ writer . close ( ) ;
275+
276+ let totalSize = 0 ;
277+ while ( true ) {
278+ const { done, value} = await reader . read ( ) ;
279+ if ( done ) break ;
280+ totalSize += value . byteLength ;
207281 }
208282
209- return headersSize + bodySize ;
283+ return totalSize ;
210284}
211285
212286/**
0 commit comments