Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
739ac6c
Specify parser-aware body hold and Next.js streaming
prk-Jr Sep 7, 2026
02f56b8
Clarify bounded Next.js streaming states
prk-Jr Sep 7, 2026
a0b5481
Complete Next.js overflow transitions
prk-Jr Sep 7, 2026
545a98c
Define RSC bypass flush transition
prk-Jr Sep 7, 2026
f33a56f
Plan parser-aware body and Next.js streaming
prk-Jr Sep 7, 2026
32de022
Address issue 850 plan review
prk-Jr Sep 7, 2026
123ef68
Make adapter parity plan executable
prk-Jr Sep 7, 2026
b177c27
Classify bounded Next.js RSC groups
prk-Jr Sep 7, 2026
304504c
Isolate bounded Next.js script capture
prk-Jr Sep 7, 2026
e5389fb
Add per-document HTML stream processors
prk-Jr Sep 7, 2026
f297e65
Stream bounded Next.js RSC groups
prk-Jr Sep 7, 2026
93cfb3f
Resolve auctions at parser-confirmed body seams
prk-Jr Sep 7, 2026
6d20f24
Document bounded Next.js streaming
prk-Jr Sep 7, 2026
5bfce98
Resolve streaming fallback review findings
prk-Jr Sep 7, 2026
6f5d329
Complete parser-aware streaming and resolve review findings
prk-Jr Sep 9, 2026
5f1d847
Merge main and preserve parser-aware streaming
prk-Jr Sep 9, 2026
d561385
Restore fragmented Next.js scripts when streaming falls back
prk-Jr Sep 10, 2026
5bf9ed6
Merge branch 'main' into fix/850-parser-aware-body-hold-nextjs-streaming
prk-Jr Sep 10, 2026
05eb493
Harden Next.js RSC claiming and make group classification incremental
prk-Jr Sep 11, 2026
481b24f
Merge branch 'main' into fix/850-parser-aware-body-hold-nextjs-streaming
prk-Jr Sep 16, 2026
66e17c4
Restore safe incremental RSC streaming
prk-Jr Sep 21, 2026
3363ee4
Merge branch 'main' into fix/850-parser-aware-body-hold-nextjs-streaming
prk-Jr Sep 21, 2026
bd18e23
Check every push in oversized RSC fragments
prk-Jr Sep 21, 2026
c7822e9
Merge branch 'main' into fix/850-parser-aware-body-hold-nextjs-streaming
prk-Jr Sep 22, 2026
f93f80d
Merge branch 'main' into fix/850-parser-aware-body-hold-nextjs-streaming
aram356 Sep 24, 2026
7a690ed
Harden RSC payload boundary iteration
prk-Jr Sep 24, 2026
dcb7e53
Preserve colons in partial RSC URL schemes
prk-Jr Sep 24, 2026
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