@@ -454,7 +454,15 @@ impl DomainFronter {
454454 url
455455 } ;
456456
457- let coalescible = is_cacheable_method ( method) && body. is_empty ( ) ;
457+ // Range requests are partial-content responses; caching or
458+ // coalescing them against a non-range key would be catastrophic
459+ // (wrong bytes for the wrong consumer). The range-parallel
460+ // downloader calls `relay()` concurrently with N different Range
461+ // headers for the same URL, and absolutely needs each call to go
462+ // to the relay independently. Simplest correct answer: if any
463+ // Range header is present, skip cache and coalesce entirely.
464+ let has_range = headers. iter ( ) . any ( |( k, _) | k. eq_ignore_ascii_case ( "range" ) ) ;
465+ let coalescible = is_cacheable_method ( method) && body. is_empty ( ) && !has_range;
458466 let key = if coalescible { Some ( cache_key ( method, url) ) } else { None } ;
459467 let t_start = Instant :: now ( ) ;
460468
@@ -507,6 +515,146 @@ impl DomainFronter {
507515 bytes
508516 }
509517
518+ /// Range-parallel relay — the big difference between this port and
519+ /// the upstream Python version. Apps Script's per-call cost is
520+ /// ~flat (1-2s regardless of payload), so a 10MB single GET is
521+ /// ~10s round-trip; the same 10MB sliced into 40 x 256KB chunks
522+ /// and fetched 16-at-a-time is 3-4 round-trips, total ~6-8s, and
523+ /// the client sees the first byte in 1-2s instead of 10. This is
524+ /// what actually makes YouTube video playback viable through the
525+ /// relay — without it, googlevideo.com chunks timeout or stall
526+ /// while the player waits for the next 10s-away Apps Script call
527+ /// to finish.
528+ ///
529+ /// Flow (mirrors upstream `relay_parallel`):
530+ /// 1. For anything other than GET-without-body, defer to
531+ /// `relay()` — range requests on POSTs / PUTs aren't well
532+ /// defined, and the user-sent-Range-header case is handled
533+ /// by relay() already (we skip cache for it).
534+ /// 2. Probe with `Range: bytes=0-<chunk-1>`.
535+ /// 3. 200 back (origin doesn't support ranges) → return as-is.
536+ /// 4. 206 back → parse Content-Range total. If the body fits in
537+ /// the first probe (total <= chunk or body >= total), rewrite
538+ /// the 206 to a 200 so the client — which never asked for a
539+ /// range — doesn't choke on a stray Partial Content. (x.com
540+ /// and Cloudflare turnstile in particular reject unsolicited
541+ /// 206 on XHR/fetch.)
542+ /// 5. Else: compute the remaining ranges, fetch them with
543+ /// bounded concurrency, stitch, return as 200.
544+ ///
545+ /// If any chunk fails after retries, we fall back to the probe's
546+ /// single-chunk response as a graceful-degradation — better a
547+ /// truncated video than a blank one.
548+ pub async fn relay_parallel_range (
549+ & self ,
550+ method : & str ,
551+ url : & str ,
552+ headers : & [ ( String , String ) ] ,
553+ body : & [ u8 ] ,
554+ ) -> Vec < u8 > {
555+ const CHUNK : u64 = 256 * 1024 ;
556+ const MAX_PARALLEL : usize = 16 ;
557+
558+ if method != "GET" || !body. is_empty ( ) {
559+ return self . relay ( method, url, headers, body) . await ;
560+ }
561+ // If the client already sent a Range header, honour it as-is —
562+ // don't second-guess a caller that knows what bytes they want.
563+ if headers. iter ( ) . any ( |( k, _) | k. eq_ignore_ascii_case ( "range" ) ) {
564+ return self . relay ( method, url, headers, body) . await ;
565+ }
566+
567+ // Probe with the first chunk.
568+ let mut probe_headers: Vec < ( String , String ) > = headers. to_vec ( ) ;
569+ probe_headers. push ( ( "Range" . into ( ) , format ! ( "bytes=0-{}" , CHUNK - 1 ) ) ) ;
570+ let first = self . relay ( method, url, & probe_headers, body) . await ;
571+
572+ let ( status, resp_headers, resp_body) = match split_response ( & first) {
573+ Some ( v) => v,
574+ None => return first,
575+ } ;
576+
577+ if status != 206 {
578+ // Origin returned the whole thing (or an error). Either way,
579+ // pass through.
580+ return first;
581+ }
582+
583+ let total = match parse_content_range_total ( & resp_headers) {
584+ Some ( t) => t,
585+ None => return rewrite_206_to_200 ( & first) ,
586+ } ;
587+
588+ if total <= CHUNK || ( resp_body. len ( ) as u64 ) >= total {
589+ return rewrite_206_to_200 ( & first) ;
590+ }
591+
592+ // Plan remaining ranges after what the probe already returned.
593+ let mut ranges: Vec < ( u64 , u64 ) > = Vec :: new ( ) ;
594+ let mut start = resp_body. len ( ) as u64 ;
595+ while start < total {
596+ let end = ( start + CHUNK - 1 ) . min ( total - 1 ) ;
597+ ranges. push ( ( start, end) ) ;
598+ start = end + 1 ;
599+ }
600+
601+ tracing:: info!(
602+ "range-parallel: {} bytes total, {} chunks remaining after probe, up to {} in flight" ,
603+ total, ranges. len( ) , MAX_PARALLEL ,
604+ ) ;
605+
606+ // Concurrent fetch with `buffered` — preserves input order
607+ // (important for stitching) and caps in-flight count. Each task
608+ // calls back into `relay()`, which already has retry + fan-out
609+ // wiring on single-request granularity; we don't duplicate
610+ // those here.
611+ use futures_util:: stream:: { self , StreamExt } ;
612+ let url_owned = url. to_string ( ) ;
613+ let base_headers = headers. to_vec ( ) ;
614+ let fetches = stream:: iter ( ranges. into_iter ( ) )
615+ . map ( |( s, e) | {
616+ let url = url_owned. clone ( ) ;
617+ let mut h = base_headers. clone ( ) ;
618+ // Force a single Range header — if the caller's headers
619+ // somehow already had one we wouldn't be here, but be
620+ // defensive anyway.
621+ h. retain ( |( k, _) | !k. eq_ignore_ascii_case ( "range" ) ) ;
622+ h. push ( ( "Range" . into ( ) , format ! ( "bytes={}-{}" , s, e) ) ) ;
623+ async move {
624+ let raw = self . relay ( "GET" , & url, & h, & [ ] ) . await ;
625+ split_response ( & raw ) . map ( |( _, _, b) | b. to_vec ( ) ) . unwrap_or_default ( )
626+ }
627+ } )
628+ . buffered ( MAX_PARALLEL )
629+ . collect :: < Vec < Vec < u8 > > > ( )
630+ . await ;
631+
632+ // Stitch: probe body first, then the chunks in order.
633+ let mut full = Vec :: with_capacity ( total as usize ) ;
634+ full. extend_from_slice ( resp_body) ;
635+ for chunk in & fetches {
636+ full. extend_from_slice ( chunk) ;
637+ }
638+
639+ // If any chunk came back empty (relay failure) we've now got a
640+ // short body. Better to ship the probe-only 200 than a silently
641+ // truncated 200 — the player will display a clear error or
642+ // retry, vs rendering half the movie and cutting.
643+ if ( full. len ( ) as u64 ) < total {
644+ tracing:: warn!(
645+ "range-parallel: stitched {}/{} bytes, some chunks failed; falling back to probe response" ,
646+ full. len( ) , total,
647+ ) ;
648+ return rewrite_206_to_200 ( & first) ;
649+ }
650+
651+ // Build a 200 OK with Content-Length = full body length. Drop
652+ // the Content-Range header (no longer applicable) and
653+ // Transfer-Encoding/Content-Encoding (origin already decoded
654+ // what we got; we ship plain bytes).
655+ assemble_full_200 ( & resp_headers, & full)
656+ }
657+
510658 async fn relay_uncoalesced (
511659 & self ,
512660 method : & str ,
@@ -779,6 +927,95 @@ impl DomainFronter {
779927/// pattern the input is returned unchanged (as an owned String — the
780928/// allocation is cheap on the slow path and keeps the caller's
781929/// type-signature-juggling simple).
930+ // ─── HTTP response helpers used by relay_parallel_range ──────────────────
931+
932+ /// Split an HTTP/1.x response blob into `(status, headers, body)`.
933+ /// Returns `None` if the buffer doesn't even have a status line + CRLFCRLF
934+ /// separator — the caller should then pass the bytes through unchanged.
935+ fn split_response ( raw : & [ u8 ] ) -> Option < ( u16 , Vec < ( String , String ) > , & [ u8 ] ) > {
936+ // Locate end-of-headers.
937+ let sep = b"\r \n \r \n " ;
938+ let sep_pos = raw. windows ( sep. len ( ) ) . position ( |w| w == sep) ?;
939+ let head = & raw [ ..sep_pos] ;
940+ let body = & raw [ sep_pos + sep. len ( ) ..] ;
941+
942+ let mut lines = head. split ( |& b| b == b'\n' ) ;
943+ let status_line = lines. next ( ) ?;
944+ // Status line: "HTTP/1.1 206 Partial Content"
945+ let status_line = std:: str:: from_utf8 ( status_line) . ok ( ) ?. trim_end_matches ( '\r' ) ;
946+ let mut parts = status_line. splitn ( 3 , ' ' ) ;
947+ let _version = parts. next ( ) ?;
948+ let code = parts. next ( ) ?. parse :: < u16 > ( ) . ok ( ) ?;
949+
950+ let mut headers: Vec < ( String , String ) > = Vec :: new ( ) ;
951+ for line in lines {
952+ let line = std:: str:: from_utf8 ( line) . ok ( ) ?. trim_end_matches ( '\r' ) ;
953+ if line. is_empty ( ) {
954+ continue ;
955+ }
956+ if let Some ( ( k, v) ) = line. split_once ( ':' ) {
957+ headers. push ( ( k. trim ( ) . to_string ( ) , v. trim ( ) . to_string ( ) ) ) ;
958+ }
959+ }
960+
961+ Some ( ( code, headers, body) )
962+ }
963+
964+ /// Pull the total size out of a `Content-Range: bytes 0-NNN/TOTAL` header.
965+ fn parse_content_range_total ( headers : & [ ( String , String ) ] ) -> Option < u64 > {
966+ let cr = headers
967+ . iter ( )
968+ . find ( |( k, _) | k. eq_ignore_ascii_case ( "content-range" ) ) ?;
969+ let slash = cr. 1 . rfind ( '/' ) ?;
970+ cr. 1 [ slash + 1 ..] . trim ( ) . parse :: < u64 > ( ) . ok ( )
971+ }
972+
973+ /// Rewrite a 206 response to a 200 OK, dropping Content-Range and
974+ /// recomputing Content-Length. Used when we probed with a synthetic
975+ /// Range header but the client sent a plain GET — handing a 206 back to
976+ /// XHR/fetch code on some sites (x.com, Cloudflare Turnstile) makes them
977+ /// treat the response as aborted. Same rationale as the upstream Python
978+ /// `_rewrite_206_to_200`.
979+ fn rewrite_206_to_200 ( raw : & [ u8 ] ) -> Vec < u8 > {
980+ let ( _status, headers, body) = match split_response ( raw) {
981+ Some ( v) => v,
982+ None => return raw. to_vec ( ) ,
983+ } ;
984+ assemble_full_200 ( & headers, body)
985+ }
986+
987+ /// Build a complete `HTTP/1.1 200 OK` response with the given header
988+ /// set + body. Skips headers the caller shouldn't be forwarding
989+ /// verbatim (content-length/range/encoding, transfer-encoding, hop-by-hop
990+ /// wire-level stuff) — we set Content-Length from the body we're
991+ /// actually shipping.
992+ fn assemble_full_200 ( src_headers : & [ ( String , String ) ] , body : & [ u8 ] ) -> Vec < u8 > {
993+ let skip = |k : & str | {
994+ matches ! (
995+ k. to_ascii_lowercase( ) . as_str( ) ,
996+ "content-length"
997+ | "content-range"
998+ | "content-encoding"
999+ | "transfer-encoding"
1000+ | "connection"
1001+ | "keep-alive" ,
1002+ )
1003+ } ;
1004+ let mut out: Vec < u8 > = b"HTTP/1.1 200 OK\r \n " . to_vec ( ) ;
1005+ for ( k, v) in src_headers {
1006+ if skip ( k) {
1007+ continue ;
1008+ }
1009+ out. extend_from_slice ( k. as_bytes ( ) ) ;
1010+ out. extend_from_slice ( b": " ) ;
1011+ out. extend_from_slice ( v. as_bytes ( ) ) ;
1012+ out. extend_from_slice ( b"\r \n " ) ;
1013+ }
1014+ out. extend_from_slice ( format ! ( "Content-Length: {}\r \n \r \n " , body. len( ) ) . as_bytes ( ) ) ;
1015+ out. extend_from_slice ( body) ;
1016+ out
1017+ }
1018+
7821019fn normalize_x_graphql_url ( url : & str ) -> String {
7831020 // Split host from the rest. We accept both "x.com" and common legacy
7841021 // forms; the Python patch only checks x.com so we do the same to be
0 commit comments