6565//! run on these responses. Legacy ran EC finalization on its own auth
6666//! challenges. Like the 401 geo-skip, this is privacy-conservative: no EC
6767//! cookies are issued to unauthenticated callers.
68- //! - **Streaming publisher responses** are buffered (bounded by
68+ //! - **Publisher responses** are buffered (bounded by
6969//! `publisher.max_buffered_body_bytes`) instead of streamed to the client.
70+ //! Asset responses are streamed straight to the client (see
71+ //! [`dispatch_asset_fallback`]), matching legacy.
7072//! - **Router-level 405s** (unregistered verbs) skip EC finalization along
7173//! with the middleware chain; the entry point still adds TS headers.
7274//!
@@ -110,12 +112,11 @@ use trusted_server_core::integrations::{
110112use trusted_server_core:: platform:: { ClientInfo , GeoInfo , PlatformKvStore , RuntimeServices } ;
111113use trusted_server_core:: proxy:: {
112114 handle_asset_proxy_request, handle_first_party_click, handle_first_party_proxy,
113- handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, stream_asset_body,
114- AssetProxyCachePolicy ,
115+ handle_first_party_proxy_rebuild, handle_first_party_proxy_sign, AssetProxyCachePolicy ,
115116} ;
116117use trusted_server_core:: publisher:: {
117118 buffer_publisher_response_async, handle_page_bids, handle_publisher_request,
118- handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch , BoundedWriter ,
119+ handle_tsjs_dynamic, page_bids_preflight_denied, AuctionDispatch ,
119120} ;
120121use trusted_server_core:: request_signing:: {
121122 handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery,
@@ -798,13 +799,13 @@ async fn dispatch_fallback(
798799 attach_dispatch_extensions ( response, ec, effects)
799800}
800801
801- /// Returns `true` when an asset response should carry a buffered body and a
802- /// recomputed `Content-Length`.
802+ /// Returns `true` when an asset response should carry a (streamed) body.
803803///
804804/// `HEAD` responses and bodiless statuses (204, 304) advertise the origin
805805/// representation length in their `Content-Length` header while carrying no
806- /// body. Rewriting that header to the buffered byte count (0) would corrupt the
807- /// metadata, so those responses keep the origin's `Content-Length` untouched.
806+ /// body. Attaching the origin stream would either contradict that header or
807+ /// stream bytes a client does not expect, so those responses drop the stream and
808+ /// keep the origin's `Content-Length` untouched.
808809fn asset_response_carries_body ( method : & Method , status : StatusCode ) -> bool {
809810 * method != Method :: HEAD
810811 && status != StatusCode :: NO_CONTENT
@@ -820,13 +821,13 @@ fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool {
820821/// is intentionally skipped: no [`EcFinalizeState`] is attached, matching the
821822/// legacy `should_finalize_ec = false` behavior for asset responses.
822823///
823- /// Unlike legacy `route_request`, which streams asset bodies straight to the
824- /// client with no cap, the `EdgeZero` path buffers them: `edgezero_main`
825- /// converts the whole response before sending, so there is no streaming seam
826- /// yet. The buffer is bounded by `publisher.max_buffered_body_bytes` as an
827- /// interim Wasm-heap OOM guard. Reusing the publisher cap and restoring
828- /// uncapped streaming are both resolved by the streaming cutover (issue #495);
829- /// whether assets get a dedicated cap is deferred to that work .
824+ /// Like legacy `route_request`, asset bodies are streamed straight to the client
825+ /// with no cap: the origin stream is attached to the response and `edgezero_main`
826+ /// commits the headers, then pipes the body chunk-by-chunk via
827+ /// [`fastly::Response::stream_to_client`] and
828+ /// [`stream_asset_body`](trusted_server_core::proxy::stream_asset_body). The
829+ /// origin stream is left untouched here, so large images never materialize in
830+ /// the Wasm heap .
830831async fn dispatch_asset_fallback (
831832 state : & AppState ,
832833 services : & RuntimeServices ,
@@ -843,30 +844,13 @@ async fn dispatch_asset_fallback(
843844 let cache_policy = asset_response. cache_policy ( ) ;
844845 let ( mut response, stream_body) = asset_response. into_response_and_body ( ) ;
845846
847+ // Attach the origin stream so `edgezero_main` streams it to the
848+ // client. HEAD and bodiless statuses (204, 304) advertise the origin
849+ // Content-Length but carry no body, so their stream is dropped to
850+ // preserve that header and avoid a length/body mismatch.
846851 if let Some ( body) = stream_body {
847- match buffer_asset_body ( body, state. settings . publisher . max_buffered_body_bytes )
848- . await
849- {
850- Ok ( bytes) => {
851- // Preserve the origin's Content-Length for HEAD and
852- // bodiless statuses; only body-bearing responses get a
853- // recomputed length and the buffered body attached.
854- if asset_response_carries_body ( & method, response. status ( ) ) {
855- response. headers_mut ( ) . insert (
856- header:: CONTENT_LENGTH ,
857- HeaderValue :: from ( bytes. len ( ) as u64 ) ,
858- ) ;
859- * response. body_mut ( ) = edgezero_core:: body:: Body :: from ( bytes) ;
860- }
861- }
862- Err ( report) => {
863- let mut response = http_error ( & report) ;
864- response
865- . extensions_mut ( )
866- . insert ( AssetProxyCachePolicy :: NoStorePrivate ) ;
867- attach_request_filter_effects ( & mut response, effects) ;
868- return response;
869- }
852+ if asset_response_carries_body ( & method, response. status ( ) ) {
853+ * response. body_mut ( ) = body;
870854 }
871855 }
872856
@@ -897,23 +881,6 @@ fn attach_request_filter_effects(response: &mut Response, effects: &RequestFilte
897881 }
898882}
899883
900- /// Buffers a streaming asset body into memory, bounded by `max_bytes`
901- /// (the interim `publisher.max_buffered_body_bytes` OOM guard; see
902- /// [`dispatch_asset_fallback`]).
903- ///
904- /// # Errors
905- ///
906- /// Returns an error if the body exceeds the configured cap or the underlying
907- /// stream yields an error.
908- async fn buffer_asset_body (
909- body : edgezero_core:: body:: Body ,
910- max_bytes : usize ,
911- ) -> Result < Vec < u8 > , Report < TrustedServerError > > {
912- let mut output = BoundedWriter :: new ( max_bytes) ;
913- stream_asset_body ( body, & mut output) . await ?;
914- Ok ( output. into_inner ( ) )
915- }
916-
917884// ---------------------------------------------------------------------------
918885// Error helper
919886// ---------------------------------------------------------------------------
@@ -1213,6 +1180,10 @@ mod tests {
12131180 build_state_from_settings, startup_error_router, AppState , NamedRouteHandler ,
12141181 TrustedServerApp , NAMED_ROUTES ,
12151182 } ;
1183+ use crate :: route_tests:: {
1184+ test_runtime_services_with_secret_http_client_and_geo, us_california_geo, FixedBackend ,
1185+ FixedGeo , NoopSecretStore , StreamingRecordingHttpClient ,
1186+ } ;
12161187
12171188 use edgezero_core:: body:: Body ;
12181189 use edgezero_core:: http:: { header, request_builder, Method , StatusCode } ;
@@ -1230,7 +1201,7 @@ mod tests {
12301201 HeaderMutation , IntegrationRegistry , IntegrationRequestFilter , RequestFilterDecision ,
12311202 RequestFilterEffects , RequestFilterInput ,
12321203 } ;
1233- use trusted_server_core:: platform:: ClientInfo ;
1204+ use trusted_server_core:: platform:: { ClientInfo , PlatformHttpClient } ;
12341205 use trusted_server_core:: settings:: Settings ;
12351206
12361207 fn settings_with_missing_consent_store ( ) -> Settings {
@@ -2108,6 +2079,79 @@ mod tests {
21082079 ) ;
21092080 }
21102081
2082+ #[ test]
2083+ fn dispatch_asset_fallback_streams_origin_body_without_buffering ( ) {
2084+ // Regression guard for the EdgeZero asset streaming cutover: a successful
2085+ // asset proxy must hand `edgezero_main` a streaming body (`Body::Stream`)
2086+ // rather than draining the origin into a buffered `Body::Once`. Injecting a
2087+ // streaming HTTP client lets us assert the body type the entry point will
2088+ // pipe straight to the client via `stream_to_client`.
2089+ let settings = Settings :: from_toml (
2090+ r#"
2091+ [[handlers]]
2092+ path = "^/_ts/admin"
2093+ username = "admin"
2094+ password = "admin-pass"
2095+
2096+ [publisher]
2097+ domain = "test-publisher.com"
2098+ cookie_domain = ".test-publisher.com"
2099+ origin_url = "https://origin.test-publisher.com"
2100+ proxy_secret = "unit-test-proxy-secret"
2101+
2102+ [ec]
2103+ passphrase = "test-secret-key-32-bytes-minimum"
2104+
2105+ [request_signing]
2106+ enabled = false
2107+ config_store_id = "test-config-store-id"
2108+ secret_store_id = "test-secret-store-id"
2109+
2110+ [proxy]
2111+
2112+ [[proxy.asset_routes]]
2113+ prefix = "/.images/"
2114+ origin_url = "https://assets.example.com"
2115+ "# ,
2116+ )
2117+ . expect ( "should parse asset-route settings" ) ;
2118+ let state = build_state_from_settings ( settings) . expect ( "should build state" ) ;
2119+
2120+ let fastly_req = fastly:: Request :: get ( "https://test-publisher.com/.images/logo.png" ) ;
2121+ let http_client = Arc :: new ( StreamingRecordingHttpClient :: new ( ) ) ;
2122+ let services = test_runtime_services_with_secret_http_client_and_geo (
2123+ & fastly_req,
2124+ Arc :: new ( FixedBackend ) ,
2125+ Arc :: new ( NoopSecretStore ) ,
2126+ Arc :: clone ( & http_client) as Arc < dyn PlatformHttpClient > ,
2127+ Arc :: new ( FixedGeo ( us_california_geo ( ) ) ) ,
2128+ ) ;
2129+ let req = crate :: compat:: from_fastly_request ( fastly_req) ;
2130+ let asset_route = state
2131+ . settings
2132+ . asset_route_for_path ( "/.images/logo.png" )
2133+ . expect ( "should match the configured asset route" ) ;
2134+ let effects = RequestFilterEffects :: default ( ) ;
2135+
2136+ let response = block_on ( super :: dispatch_asset_fallback (
2137+ & state,
2138+ & services,
2139+ req,
2140+ asset_route,
2141+ & effects,
2142+ ) ) ;
2143+
2144+ assert_eq ! (
2145+ response. status( ) ,
2146+ StatusCode :: OK ,
2147+ "should preserve the streaming origin status"
2148+ ) ;
2149+ assert ! (
2150+ matches!( response. body( ) , Body :: Stream ( _) ) ,
2151+ "EdgeZero asset dispatch must stream the origin body, not buffer it"
2152+ ) ;
2153+ }
2154+
21112155 #[ test]
21122156 fn dispatch_runs_request_filter_and_threads_response_effects ( ) {
21132157 // Regression guard for the EdgeZero request-filter bypass: the publisher
0 commit comments