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,11 +112,10 @@ 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:: {
117- buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic, BoundedWriter ,
118+ buffer_publisher_response, handle_publisher_request, handle_tsjs_dynamic,
118119} ;
119120use trusted_server_core:: request_signing:: {
120121 handle_deactivate_key, handle_rotate_key, handle_trusted_server_discovery,
@@ -734,13 +735,13 @@ async fn dispatch_fallback(
734735 attach_dispatch_extensions ( response, ec, effects)
735736}
736737
737- /// Returns `true` when an asset response should carry a buffered body and a
738- /// recomputed `Content-Length`.
738+ /// Returns `true` when an asset response should carry a (streamed) body.
739739///
740740/// `HEAD` responses and bodiless statuses (204, 304) advertise the origin
741741/// representation length in their `Content-Length` header while carrying no
742- /// body. Rewriting that header to the buffered byte count (0) would corrupt the
743- /// metadata, so those responses keep the origin's `Content-Length` untouched.
742+ /// body. Attaching the origin stream would either contradict that header or
743+ /// stream bytes a client does not expect, so those responses drop the stream and
744+ /// keep the origin's `Content-Length` untouched.
744745fn asset_response_carries_body ( method : & Method , status : StatusCode ) -> bool {
745746 * method != Method :: HEAD
746747 && status != StatusCode :: NO_CONTENT
@@ -756,13 +757,13 @@ fn asset_response_carries_body(method: &Method, status: StatusCode) -> bool {
756757/// is intentionally skipped: no [`EcFinalizeState`] is attached, matching the
757758/// legacy `should_finalize_ec = false` behavior for asset responses.
758759///
759- /// Unlike legacy `route_request`, which streams asset bodies straight to the
760- /// client with no cap, the `EdgeZero` path buffers them: `edgezero_main`
761- /// converts the whole response before sending, so there is no streaming seam
762- /// yet. The buffer is bounded by `publisher.max_buffered_body_bytes` as an
763- /// interim Wasm-heap OOM guard. Reusing the publisher cap and restoring
764- /// uncapped streaming are both resolved by the streaming cutover (issue #495);
765- /// whether assets get a dedicated cap is deferred to that work .
760+ /// Like legacy `route_request`, asset bodies are streamed straight to the client
761+ /// with no cap: the origin stream is attached to the response and `edgezero_main`
762+ /// commits the headers, then pipes the body chunk-by-chunk via
763+ /// [`fastly::Response::stream_to_client`] and
764+ /// [`stream_asset_body`](trusted_server_core::proxy::stream_asset_body). The
765+ /// origin stream is left untouched here, so large images never materialize in
766+ /// the Wasm heap .
766767async fn dispatch_asset_fallback (
767768 state : & AppState ,
768769 services : & RuntimeServices ,
@@ -779,30 +780,13 @@ async fn dispatch_asset_fallback(
779780 let cache_policy = asset_response. cache_policy ( ) ;
780781 let ( mut response, stream_body) = asset_response. into_response_and_body ( ) ;
781782
783+ // Attach the origin stream so `edgezero_main` streams it to the
784+ // client. HEAD and bodiless statuses (204, 304) advertise the origin
785+ // Content-Length but carry no body, so their stream is dropped to
786+ // preserve that header and avoid a length/body mismatch.
782787 if let Some ( body) = stream_body {
783- match buffer_asset_body ( body, state. settings . publisher . max_buffered_body_bytes )
784- . await
785- {
786- Ok ( bytes) => {
787- // Preserve the origin's Content-Length for HEAD and
788- // bodiless statuses; only body-bearing responses get a
789- // recomputed length and the buffered body attached.
790- if asset_response_carries_body ( & method, response. status ( ) ) {
791- response. headers_mut ( ) . insert (
792- header:: CONTENT_LENGTH ,
793- HeaderValue :: from ( bytes. len ( ) as u64 ) ,
794- ) ;
795- * response. body_mut ( ) = edgezero_core:: body:: Body :: from ( bytes) ;
796- }
797- }
798- Err ( report) => {
799- let mut response = http_error ( & report) ;
800- response
801- . extensions_mut ( )
802- . insert ( AssetProxyCachePolicy :: NoStorePrivate ) ;
803- attach_request_filter_effects ( & mut response, effects) ;
804- return response;
805- }
788+ if asset_response_carries_body ( & method, response. status ( ) ) {
789+ * response. body_mut ( ) = body;
806790 }
807791 }
808792
@@ -833,23 +817,6 @@ fn attach_request_filter_effects(response: &mut Response, effects: &RequestFilte
833817 }
834818}
835819
836- /// Buffers a streaming asset body into memory, bounded by `max_bytes`
837- /// (the interim `publisher.max_buffered_body_bytes` OOM guard; see
838- /// [`dispatch_asset_fallback`]).
839- ///
840- /// # Errors
841- ///
842- /// Returns an error if the body exceeds the configured cap or the underlying
843- /// stream yields an error.
844- async fn buffer_asset_body (
845- body : edgezero_core:: body:: Body ,
846- max_bytes : usize ,
847- ) -> Result < Vec < u8 > , Report < TrustedServerError > > {
848- let mut output = BoundedWriter :: new ( max_bytes) ;
849- stream_asset_body ( body, & mut output) . await ?;
850- Ok ( output. into_inner ( ) )
851- }
852-
853820// ---------------------------------------------------------------------------
854821// Error helper
855822// ---------------------------------------------------------------------------
@@ -1141,6 +1108,10 @@ mod tests {
11411108 build_state_from_settings, startup_error_router, AppState , NamedRouteHandler ,
11421109 TrustedServerApp , NAMED_ROUTES ,
11431110 } ;
1111+ use crate :: route_tests:: {
1112+ test_runtime_services_with_secret_http_client_and_geo, us_california_geo, FixedBackend ,
1113+ FixedGeo , NoopSecretStore , StreamingRecordingHttpClient ,
1114+ } ;
11441115
11451116 use edgezero_core:: body:: Body ;
11461117 use edgezero_core:: http:: { header, request_builder, Method , StatusCode } ;
@@ -1158,7 +1129,7 @@ mod tests {
11581129 HeaderMutation , IntegrationRegistry , IntegrationRequestFilter , RequestFilterDecision ,
11591130 RequestFilterEffects , RequestFilterInput ,
11601131 } ;
1161- use trusted_server_core:: platform:: ClientInfo ;
1132+ use trusted_server_core:: platform:: { ClientInfo , PlatformHttpClient } ;
11621133 use trusted_server_core:: settings:: Settings ;
11631134
11641135 fn settings_with_missing_consent_store ( ) -> Settings {
@@ -2036,6 +2007,79 @@ mod tests {
20362007 ) ;
20372008 }
20382009
2010+ #[ test]
2011+ fn dispatch_asset_fallback_streams_origin_body_without_buffering ( ) {
2012+ // Regression guard for the EdgeZero asset streaming cutover: a successful
2013+ // asset proxy must hand `edgezero_main` a streaming body (`Body::Stream`)
2014+ // rather than draining the origin into a buffered `Body::Once`. Injecting a
2015+ // streaming HTTP client lets us assert the body type the entry point will
2016+ // pipe straight to the client via `stream_to_client`.
2017+ let settings = Settings :: from_toml (
2018+ r#"
2019+ [[handlers]]
2020+ path = "^/_ts/admin"
2021+ username = "admin"
2022+ password = "admin-pass"
2023+
2024+ [publisher]
2025+ domain = "test-publisher.com"
2026+ cookie_domain = ".test-publisher.com"
2027+ origin_url = "https://origin.test-publisher.com"
2028+ proxy_secret = "unit-test-proxy-secret"
2029+
2030+ [ec]
2031+ passphrase = "test-secret-key-32-bytes-minimum"
2032+
2033+ [request_signing]
2034+ enabled = false
2035+ config_store_id = "test-config-store-id"
2036+ secret_store_id = "test-secret-store-id"
2037+
2038+ [proxy]
2039+
2040+ [[proxy.asset_routes]]
2041+ prefix = "/.images/"
2042+ origin_url = "https://assets.example.com"
2043+ "# ,
2044+ )
2045+ . expect ( "should parse asset-route settings" ) ;
2046+ let state = build_state_from_settings ( settings) . expect ( "should build state" ) ;
2047+
2048+ let fastly_req = fastly:: Request :: get ( "https://test-publisher.com/.images/logo.png" ) ;
2049+ let http_client = Arc :: new ( StreamingRecordingHttpClient :: new ( ) ) ;
2050+ let services = test_runtime_services_with_secret_http_client_and_geo (
2051+ & fastly_req,
2052+ Arc :: new ( FixedBackend ) ,
2053+ Arc :: new ( NoopSecretStore ) ,
2054+ Arc :: clone ( & http_client) as Arc < dyn PlatformHttpClient > ,
2055+ Arc :: new ( FixedGeo ( us_california_geo ( ) ) ) ,
2056+ ) ;
2057+ let req = crate :: compat:: from_fastly_request ( fastly_req) ;
2058+ let asset_route = state
2059+ . settings
2060+ . asset_route_for_path ( "/.images/logo.png" )
2061+ . expect ( "should match the configured asset route" ) ;
2062+ let effects = RequestFilterEffects :: default ( ) ;
2063+
2064+ let response = block_on ( super :: dispatch_asset_fallback (
2065+ & state,
2066+ & services,
2067+ req,
2068+ asset_route,
2069+ & effects,
2070+ ) ) ;
2071+
2072+ assert_eq ! (
2073+ response. status( ) ,
2074+ StatusCode :: OK ,
2075+ "should preserve the streaming origin status"
2076+ ) ;
2077+ assert ! (
2078+ matches!( response. body( ) , Body :: Stream ( _) ) ,
2079+ "EdgeZero asset dispatch must stream the origin body, not buffer it"
2080+ ) ;
2081+ }
2082+
20392083 #[ test]
20402084 fn dispatch_runs_request_filter_and_threads_response_effects ( ) {
20412085 // Regression guard for the EdgeZero request-filter bypass: the publisher
0 commit comments