Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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