Skip to content

Commit 7ae1fea

Browse files
authored
Merge pull request #385 from csfloat/feature/improve-notary-response-size-estimate
[Notary] Improves Estimate of Response Size when Content-Length Absent
2 parents 8108d99 + dfceef0 commit 7ae1fea

1 file changed

Lines changed: 84 additions & 10 deletions

File tree

src/offscreen/handlers/notary_prove.ts

Lines changed: 84 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)