Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/trusted-server-adapter-axum/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "ti
trusted-server-core = { workspace = true }

[dev-dependencies]
trusted-server-core = { workspace = true, features = ["test-utils"] }
axum = { workspace = true }
base64 = { workspace = true }
temp-env = { workspace = true }
Expand Down
43 changes: 42 additions & 1 deletion crates/trusted-server-adapter-axum/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub struct AppState {
settings: Arc<Settings>,
orchestrator: Arc<AuctionOrchestrator>,
registry: Arc<IntegrationRegistry>,
services: Option<RuntimeServices>,
}

/// Build the application state, loading settings and constructing all per-application components.
Expand Down Expand Up @@ -80,6 +81,13 @@ fn build_state() -> Result<Arc<AppState>, Report<TrustedServerError>> {
/// registry fail to initialise.
fn build_state_with_settings(
settings: Settings,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
build_state_with_services(settings, None)
}

fn build_state_with_services(
settings: Settings,
services: Option<RuntimeServices>,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
let plan = Arc::new(compile_auction_plan(&settings)?);
plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Axum)?;
Expand All @@ -90,9 +98,18 @@ fn build_state_with_settings(
settings: Arc::new(settings),
orchestrator: Arc::new(orchestrator),
registry: Arc::new(registry),
services,
}))
}

impl AppState {
fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices {
self.services
.clone()
.unwrap_or_else(|| build_runtime_services(ctx))
}
}

// ---------------------------------------------------------------------------
// Error helper
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -142,7 +159,7 @@ where
F: FnOnce(Arc<AppState>, RuntimeServices, Request) -> Fut,
Fut: Future<Output = Result<Response, Report<TrustedServerError>>>,
{
let services = build_runtime_services(&ctx);
let services = state.services_for_request(&ctx);
let mut req = ctx.into_request();
if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request(
&state.settings,
Expand Down Expand Up @@ -603,6 +620,30 @@ impl TrustedServerApp {
let state = build_state_with_settings(settings)?;
Ok(build_router(&state))
}

/// Build the full router with explicit settings and runtime services.
///
/// Each request receives a clone of the supplied services, allowing callers
/// to exercise production routes with deterministic platform dependencies.
/// The supplied client metadata applies to every request to this router.
///
/// # Errors
///
/// Returns an error when the auction orchestrator or integration registry
/// cannot be initialized.
///
/// # Examples
///
/// ```ignore
/// let router = TrustedServerApp::routes_with_settings_and_services(settings, services)?;
/// ```
pub fn routes_with_settings_and_services(
settings: Settings,
services: RuntimeServices,
) -> Result<RouterService, Report<TrustedServerError>> {
let state = build_state_with_services(settings, Some(services))?;
Ok(build_router(&state))
}
}

fn build_router(state: &Arc<AppState>) -> RouterService {
Expand Down
71 changes: 71 additions & 0 deletions crates/trusted-server-adapter-axum/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -867,3 +867,74 @@ async fn first_party_proxy_rebuild_is_routed() {
"/first-party/proxy-rebuild must be routed"
);
}

/// Regression test: a Next.js navigation with a pending auction must buffer to
/// the structural body close. The Flight payload carries a literal `</body>`, so
/// a parser-blind seam would inject bids early and split the RSC data.
///
/// This covers the buffered path only. This adapter routes navigations through
/// `buffer_publisher_response_async`, which resolves the body close without the
/// deferred inline seam marker, so the streaming seam token is exercised by the
/// Fastly adapter alone and not by this test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nextjs_auction_output_holds_until_the_structural_body_close() {
use std::sync::Arc;

use trusted_server_core::test_support::nextjs_auction;

let client = Arc::new(nextjs_auction::NextJsAuctionOrigin::default());
let router = TrustedServerApp::routes_with_settings_and_services(
nextjs_auction::settings(),
nextjs_auction::services(Arc::clone(&client)),
)
.expect("should build router with fixture services");

let request = edgezero_core::http::request_builder()
.method("GET")
.uri("https://test-publisher.example.com/article")
.header("host", "test-publisher.example.com")
.header("accept", "text/html")
.body(edgezero_core::body::Body::empty())
.expect("should build publisher navigation");
let response = router
.oneshot(request)
.await
.expect("should serve publisher navigation");
assert_eq!(response.status(), 200, "should serve fixture HTML");
let body = response
.into_body()
.into_bytes()
.expect("should buffer adapter output");
let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML");

assert_eq!(
client.auction_requests(),
1,
"should dispatch exactly one auction"
);
let bids = html
.find("var b=JSON.parse(")
.unwrap_or_else(|| panic!("should inject auction bids: {html}"));
let close = html
.rfind("</body>")
.unwrap_or_else(|| panic!("should retain structural close: {html}"));
assert!(
bids < close && html[bids..].ends_with("</script></body></html>"),
"should inject bids immediately before the structural body close: {html}"
);
// The fixture splits the URL across two scripts, so the rewritten payload
// never appears contiguously. Assert on the recomputed `T` length instead:
// it shrinks only when the origin URL was actually replaced.
assert!(
html.contains(&nextjs_auction::expected_rewritten_flight_header()),
"should recompute the Flight T length after rewriting the URL: {html}"
);
assert!(
!html.contains(nextjs_auction::ORIGIN_HOST),
"should leave no origin host in the rewritten payload: {html}"
);
assert!(
!html.contains("__ts_rsc_") && !html.contains("<!--ts-inline-body-close-"),
"should not leak generated placeholders: {html}"
);
}
1 change: 1 addition & 0 deletions crates/trusted-server-adapter-cloudflare/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ js-sys = { workspace = true }
worker = { workspace = true }

[dev-dependencies]
trusted-server-core = { workspace = true, features = ["test-utils"] }
base64 = { workspace = true }
edgezero-core = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
49 changes: 43 additions & 6 deletions crates/trusted-server-adapter-cloudflare/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ pub struct AppState {
settings: Arc<Settings>,
orchestrator: Arc<AuctionOrchestrator>,
registry: Arc<IntegrationRegistry>,
services: Option<RuntimeServices>,
}

/// Build the application state, loading settings and constructing all per-application components.
Expand Down Expand Up @@ -141,6 +142,13 @@ fn settings_from_cloudflare_config_json() -> Result<Settings, Report<TrustedServ
/// registry fail to initialise.
fn build_state_with_settings(
settings: Settings,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
build_state_with_services(settings, None)
}

fn build_state_with_services(
settings: Settings,
services: Option<RuntimeServices>,
) -> Result<Arc<AppState>, Report<TrustedServerError>> {
let plan = Arc::new(compile_auction_plan(&settings)?);
plan.validate_for_target(trusted_server_core::platform::AuctionTargetId::Cloudflare)?;
Expand All @@ -151,17 +159,22 @@ fn build_state_with_settings(
settings: Arc::new(settings),
orchestrator: Arc::new(orchestrator),
registry: Arc::new(registry),
services,
}))
}

impl AppState {
fn services_for_request(&self, ctx: &RequestContext) -> RuntimeServices {
self.services
.clone()
.unwrap_or_else(|| build_runtime_services(ctx))
}
}

// ---------------------------------------------------------------------------
// Per-request RuntimeServices
// ---------------------------------------------------------------------------

fn build_per_request_services(ctx: &RequestContext) -> RuntimeServices {
build_runtime_services(ctx)
}

/// Builds the geo-aware [`EcContext`] for consent-gated endpoints (`/auction`,
/// `/_ts/page-bids`, and the publisher fallback).
///
Expand Down Expand Up @@ -209,7 +222,7 @@ where
let s = Arc::clone(&state);
let f = f.clone();
Box::pin(async move {
let services = build_per_request_services(&ctx);
let services = s.services_for_request(&ctx);
let mut req = ctx.into_request();
if let Err(error) = trusted_server_core::integrations::gpt_diagnostics::prepare_request(
&s.settings,
Expand Down Expand Up @@ -396,6 +409,30 @@ impl TrustedServerApp {
let state = build_state_with_settings(settings)?;
Ok(build_router(&state))
}

/// Build the full router with explicit settings and runtime services.
///
/// Each request receives a clone of the supplied services, allowing callers
/// to exercise production routes with deterministic platform dependencies.
/// The supplied client metadata applies to every request to this router.
///
/// # Errors
///
/// Returns an error when the auction orchestrator or integration registry
/// cannot be initialized.
///
/// # Examples
///
/// ```ignore
/// let router = TrustedServerApp::routes_with_settings_and_services(settings, services)?;
/// ```
pub fn routes_with_settings_and_services(
settings: Settings,
services: RuntimeServices,
) -> Result<RouterService, Report<TrustedServerError>> {
let state = build_state_with_services(settings, Some(services))?;
Ok(build_router(&state))
}
}

fn build_router(state: &Arc<AppState>) -> RouterService {
Expand All @@ -407,7 +444,7 @@ fn build_router(state: &Arc<AppState>) -> RouterService {
state: Arc<AppState>,
ctx: RequestContext,
) -> Result<Response, EdgeError> {
let services = build_per_request_services(&ctx);
let services = state.services_for_request(&ctx);
let mut req = ctx.into_request();
if let Some(response) = deny_admin_diagnostic_fallback(&req) {
return Ok(response);
Expand Down
71 changes: 71 additions & 0 deletions crates/trusted-server-adapter-cloudflare/tests/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -675,3 +675,74 @@ async fn tsjs_route_prefix_is_handled_not_5xx() {
"tsjs catch-all handler must not return 5xx: got {status}"
);
}

/// Regression test: a Next.js navigation with a pending auction must buffer to
/// the structural body close. The Flight payload carries a literal `</body>`, so
/// a parser-blind seam would inject bids early and split the RSC data.
///
/// This covers the buffered path only. This adapter routes navigations through
/// `buffer_publisher_response_async`, which resolves the body close without the
/// deferred inline seam marker, so the streaming seam token is exercised by the
/// Fastly adapter alone and not by this test.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nextjs_auction_output_holds_until_the_structural_body_close() {
use std::sync::Arc;

use trusted_server_core::test_support::nextjs_auction;

let client = Arc::new(nextjs_auction::NextJsAuctionOrigin::default());
let router = TrustedServerApp::routes_with_settings_and_services(
nextjs_auction::settings(),
nextjs_auction::services(Arc::clone(&client)),
)
.expect("should build router with fixture services");

let request = edgezero_core::http::request_builder()
.method("GET")
.uri("https://test-publisher.example.com/article")
.header("host", "test-publisher.example.com")
.header("accept", "text/html")
.body(edgezero_core::body::Body::empty())
.expect("should build publisher navigation");
let response = router
.oneshot(request)
.await
.expect("should serve publisher navigation");
assert_eq!(response.status(), 200, "should serve fixture HTML");
let body = response
.into_body()
.into_bytes()
.expect("should buffer adapter output");
let html = String::from_utf8(body.to_vec()).expect("should emit UTF-8 HTML");

assert_eq!(
client.auction_requests(),
1,
"should dispatch exactly one auction"
);
let bids = html
.find("var b=JSON.parse(")
.unwrap_or_else(|| panic!("should inject auction bids: {html}"));
let close = html
.rfind("</body>")
.unwrap_or_else(|| panic!("should retain structural close: {html}"));
assert!(
bids < close && html[bids..].ends_with("</script></body></html>"),
"should inject bids immediately before the structural body close: {html}"
);
// The fixture splits the URL across two scripts, so the rewritten payload
// never appears contiguously. Assert on the recomputed `T` length instead:
// it shrinks only when the origin URL was actually replaced.
assert!(
html.contains(&nextjs_auction::expected_rewritten_flight_header()),
"should recompute the Flight T length after rewriting the URL: {html}"
);
assert!(
!html.contains(nextjs_auction::ORIGIN_HOST),
"should leave no origin host in the rewritten payload: {html}"
);
assert!(
!html.contains("__ts_rsc_") && !html.contains("<!--ts-inline-body-close-"),
"should not leak generated placeholders: {html}"
);
}
1 change: 1 addition & 0 deletions crates/trusted-server-adapter-spin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ trusted-server-js = { workspace = true }
spin-sdk = { workspace = true }

[dev-dependencies]
trusted-server-core = { workspace = true, features = ["test-utils"] }
base64 = { workspace = true }
edgezero-core = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
Loading
Loading