From 4e2483a101e286d0c80ca077cad1e594bdeb52c8 Mon Sep 17 00:00:00 2001 From: Joel Dice Date: Tue, 17 Dec 2024 18:30:09 +0000 Subject: [PATCH 1/2] prototype support for 1xx informational responses This is a prototype intended to spur discussion about what support for 1xx informational responses should look like in a Hyper server. The good news is that it works great (for HTTP/1 only, so far). The bad news is it's kind of ugly. Here's what I did: - Add `ext::InformationalSender`, a type which wraps a `futures_channel::mspc::Sender>`. This may be added as an extension to an inbound `Request` by the Hyper server, and the application and/or middleware may use it to send one or more informational responses before sending the real one. - Add code to `proto::h1::dispatch` and friends to add such an extension to each inbound request and then poll the `Receiver` end along with the future representing the final response. If the app never sends any informational responses, then everything proceeds as normal. Otherwise, we send those responses as they become available until the final response is ready. TODO items: - [ ] Also support informational responses in the HTTP/2 server. - [ ] Determine best way to handle when the app sends an informational response with a non-1xx status code. Currently we just silently ignore it. - [ ] Come up with a less hacky API? - [ ] Add test coverage. Signed-off-by: Joel Dice --- src/error.rs | 15 -------- src/ext/mod.rs | 10 ++++++ src/proto/h1/conn.rs | 2 +- src/proto/h1/dispatch.rs | 74 ++++++++++++++++++++++++++++++++-------- src/proto/h1/mod.rs | 3 +- src/proto/h1/role.rs | 43 ++++++++++++----------- 6 files changed, 96 insertions(+), 51 deletions(-) diff --git a/src/error.rs b/src/error.rs index 8b41f9c93d..765a904052 100644 --- a/src/error.rs +++ b/src/error.rs @@ -146,10 +146,6 @@ pub(super) enum User { #[cfg(any(feature = "http1", feature = "http2"))] #[cfg(feature = "server")] UnexpectedHeader, - /// User tried to respond with a 1xx (not 101) response code. - #[cfg(feature = "http1")] - #[cfg(feature = "server")] - UnsupportedStatusCode, /// User tried polling for an upgrade that doesn't exist. NoUpgrade, @@ -392,12 +388,6 @@ impl Error { Error::new(Kind::HeaderTimeout) } - #[cfg(feature = "http1")] - #[cfg(feature = "server")] - pub(super) fn new_user_unsupported_status_code() -> Error { - Error::new_user(User::UnsupportedStatusCode) - } - pub(super) fn new_user_no_upgrade() -> Error { Error::new_user(User::NoUpgrade) } @@ -537,11 +527,6 @@ impl Error { #[cfg(any(feature = "http1", feature = "http2"))] #[cfg(feature = "server")] Kind::User(User::UnexpectedHeader) => "user sent unexpected header", - #[cfg(feature = "http1")] - #[cfg(feature = "server")] - Kind::User(User::UnsupportedStatusCode) => { - "response has 1xx status code, not supported by server" - } Kind::User(User::NoUpgrade) => "no upgrade available", #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] Kind::User(User::ManualUpgrade) => "upgrade expected but low level API in use", diff --git a/src/ext/mod.rs b/src/ext/mod.rs index b59d809dea..068ed3b8a6 100644 --- a/src/ext/mod.rs +++ b/src/ext/mod.rs @@ -293,3 +293,13 @@ impl OriginalHeaderOrder { self.entry_order.iter() } } + +/// Request extension type for sending one or more 1xx informational responses +/// prior to the final response. +/// +/// This extension is meant to be attached to inbound `Request`s, allowing a +/// server to send informational responses immediately (i.e. without delaying +/// them until it has constructed a final, non-informational response). +#[cfg(all(feature = "server", any(feature = "http1", feature = "http2")))] +#[derive(Clone, Debug)] +pub struct InformationalSender(pub futures_channel::mpsc::Sender>); diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 3d71ed5bc5..ff49c623b2 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -641,7 +641,7 @@ where head.extensions.remove::(); } - Some(encoder) + encoder } Err(err) => { self.state.error = Some(err); diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 5daeb5ebf6..d852e55e6d 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -8,14 +8,22 @@ use std::{ use crate::rt::{Read, Write}; use bytes::{Buf, Bytes}; -use futures_core::ready; +#[cfg(feature = "server")] +use futures_channel::mpsc::{self, Receiver}; +use futures_util::ready; +#[cfg(feature = "server")] +use futures_util::StreamExt; use http::Request; +#[cfg(feature = "server")] +use http::Response; use super::{Http1Transaction, Wants}; use crate::body::{Body, DecodedLength, Incoming as IncomingBody}; #[cfg(feature = "client")] use crate::client::dispatch::TrySendError; use crate::common::task; +#[cfg(feature = "server")] +use crate::ext::InformationalSender; use crate::proto::{BodyLength, Conn, Dispatched, MessageHead, RequestHead}; use crate::upgrade::OnUpgrade; @@ -35,7 +43,7 @@ pub(crate) trait Dispatch { fn poll_msg( self: Pin<&mut Self>, cx: &mut Context<'_>, - ) -> Poll>>; + ) -> Poll), Self::PollError>>>; fn recv_msg(&mut self, msg: crate::Result<(Self::RecvItem, IncomingBody)>) -> crate::Result<()>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll>; @@ -46,6 +54,7 @@ cfg_server! { use crate::service::HttpService; pub(crate) struct Server, B> { + informational_rx: Option>>, in_flight: Pin>>, pub(crate) service: S, } @@ -336,17 +345,22 @@ where if let Some(msg) = ready!(Pin::new(&mut self.dispatch).poll_msg(cx)) { let (head, body) = msg.map_err(crate::Error::new_user_service)?; - let body_type = if body.is_end_stream() { + let body_type = if let Some(body) = body { + if body.is_end_stream() { + self.body_rx.set(None); + None + } else { + let btype = body + .size_hint() + .exact() + .map(BodyLength::Known) + .or(Some(BodyLength::Unknown)); + self.body_rx.set(Some(body)); + btype + } + } else { self.body_rx.set(None); None - } else { - let btype = body - .size_hint() - .exact() - .map(BodyLength::Known) - .or(Some(BodyLength::Unknown)); - self.body_rx.set(Some(body)); - btype }; self.conn.write_head(head, body_type); } else { @@ -505,6 +519,7 @@ cfg_server! { { pub(crate) fn new(service: S) -> Server { Server { + informational_rx: None, in_flight: Box::pin(None), service, } @@ -532,8 +547,33 @@ cfg_server! { fn poll_msg( mut self: Pin<&mut Self>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll), Self::PollError>>> { let mut this = self.as_mut(); + + if let Some(informational_rx) = this.informational_rx.as_mut() { + if let Poll::Ready(informational) = informational_rx.poll_next_unpin(cx) { + if let Some(informational) = informational { + let (parts, _) = informational.into_parts(); + if parts.status.is_informational() { + let head = MessageHead { + version: parts.version, + subject: parts.status, + headers: parts.headers, + extensions: parts.extensions, + }; + return Poll::Ready(Some(Ok((head, None)))); + } else { + // TODO: We should return an error here, but we have + // no way of creating a `Self::PollError`; might + // need to change the signature of + // `Dispatch::poll_msg`. + } + } else { + this.informational_rx = None; + } + } + } + let ret = if let Some(ref mut fut) = this.in_flight.as_mut().as_pin_mut() { let resp = ready!(fut.as_mut().poll(cx)?); let (parts, body) = resp.into_parts(); @@ -543,13 +583,14 @@ cfg_server! { headers: parts.headers, extensions: parts.extensions, }; - Poll::Ready(Some(Ok((head, body)))) + Poll::Ready(Some(Ok((head, Some(body))))) } else { unreachable!("poll_msg shouldn't be called if no inflight"); }; // Since in_flight finished, remove it this.in_flight.set(None); + this.informational_rx = None; ret } @@ -561,7 +602,10 @@ cfg_server! { *req.headers_mut() = msg.headers; *req.version_mut() = msg.version; *req.extensions_mut() = msg.extensions; + let (informational_tx, informational_rx) = mpsc::channel(1); + assert!(req.extensions_mut().insert(InformationalSender(informational_tx)).is_none()); let fut = self.service.call(req); + self.informational_rx = Some(informational_rx); self.in_flight.set(Some(fut)); Ok(()) } @@ -607,7 +651,7 @@ cfg_client! { fn poll_msg( mut self: Pin<&mut Self>, cx: &mut Context<'_>, - ) -> Poll>> { + ) -> Poll), Infallible>>> { let mut this = self.as_mut(); debug_assert!(!this.rx_closed); match this.rx.poll_recv(cx) { @@ -627,7 +671,7 @@ cfg_client! { extensions: parts.extensions, }; this.callback = Some(cb); - Poll::Ready(Some(Ok((head, body)))) + Poll::Ready(Some(Ok((head, Some(body))))) } } } diff --git a/src/proto/h1/mod.rs b/src/proto/h1/mod.rs index a8f36f5fd9..d49c33b144 100644 --- a/src/proto/h1/mod.rs +++ b/src/proto/h1/mod.rs @@ -33,7 +33,8 @@ pub(crate) trait Http1Transaction { #[cfg(feature = "tracing")] const LOG: &'static str; fn parse(bytes: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult; - fn encode(enc: Encode<'_, Self::Outgoing>, dst: &mut Vec) -> crate::Result; + fn encode(enc: Encode<'_, Self::Outgoing>, dst: &mut Vec) + -> crate::Result>; fn on_error(err: &crate::Error) -> Option>; diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index f124c9ff2b..ab062599f7 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -111,7 +111,7 @@ fn is_complete_fast(bytes: &[u8], prev_len: usize) -> bool { pub(super) fn encode_headers( enc: Encode<'_, T::Outgoing>, dst: &mut Vec, -) -> crate::Result +) -> crate::Result> where T: Http1Transaction, { @@ -356,7 +356,10 @@ impl Http1Transaction for Server { })) } - fn encode(mut msg: Encode<'_, Self::Outgoing>, dst: &mut Vec) -> crate::Result { + fn encode( + msg: Encode<'_, Self::Outgoing>, + dst: &mut Vec, + ) -> crate::Result> { trace!( "Server::encode status={:?}, body={:?}, req_method={:?}", msg.head.subject, @@ -366,25 +369,19 @@ impl Http1Transaction for Server { let mut wrote_len = false; - // hyper currently doesn't support returning 1xx status codes as a Response - // This is because Service only allows returning a single Response, and - // so if you try to reply with a e.g. 100 Continue, you have no way of - // replying with the latter status code response. - let (ret, is_last) = if msg.head.subject == StatusCode::SWITCHING_PROTOCOLS { - (Ok(()), true) + let informational = msg.head.subject.is_informational(); + + let is_last = if msg.head.subject == StatusCode::SWITCHING_PROTOCOLS { + true } else if msg.req_method == &Some(Method::CONNECT) && msg.head.subject.is_success() { // Sending content-length or transfer-encoding header on 2xx response // to CONNECT is forbidden in RFC 7231. wrote_len = true; - (Ok(()), true) - } else if msg.head.subject.is_informational() { - warn!("response with 1xx status code not supported"); - *msg.head = MessageHead::default(); - msg.head.subject = StatusCode::INTERNAL_SERVER_ERROR; - msg.body = None; - (Err(crate::Error::new_user_unsupported_status_code()), true) + true + } else if informational { + false } else { - (Ok(()), !msg.keep_alive) + !msg.keep_alive }; // In some error cases, we don't know about the invalid message until already @@ -442,6 +439,7 @@ impl Http1Transaction for Server { } orig_headers => orig_headers, }; + let encoder = if let Some(orig_headers) = orig_headers { Self::encode_headers_with_original_case( msg, @@ -455,7 +453,11 @@ impl Http1Transaction for Server { Self::encode_headers_with_lower_case(msg, dst, is_last, orig_len, wrote_len)? }; - ret.map(|()| encoder) + // If we're sending a 1xx informational response, it won't have a body, + // so we'll return `None` here. Additionally, that will tell + // `Conn::write_head` to stay in the `Writing::Init` state since we + // haven't yet sent the final response. + Ok(if informational { None } else { Some(encoder) }) } fn on_error(err: &crate::Error) -> Option> { @@ -1165,7 +1167,10 @@ impl Http1Transaction for Client { } } - fn encode(msg: Encode<'_, Self::Outgoing>, dst: &mut Vec) -> crate::Result { + fn encode( + msg: Encode<'_, Self::Outgoing>, + dst: &mut Vec, + ) -> crate::Result> { trace!( "Client::encode method={:?}, body={:?}", msg.head.subject.0, @@ -1211,7 +1216,7 @@ impl Http1Transaction for Client { extend(dst, b"\r\n"); msg.head.headers.clear(); //TODO: remove when switching to drain() - Ok(body) + Ok(Some(body)) } fn on_error(_err: &crate::Error) -> Option> { From 6810b2bfa9059149e3d46cce2d27d6fc7930b5d7 Mon Sep 17 00:00:00 2001 From: Apu Islam Date: Tue, 9 Dec 2025 11:39:06 +0000 Subject: [PATCH 2/2] feat(http2): implement 103 Early Hints support Add complete HTTP/2 103 Early Hints implementation with client and server support: - Add InformationalSender extension for server-side hint transmission via mpsc channel - Create InformationalCallback system for client-side informational response handling - Extend HTTP/2 client builder with informational_responses() configuration method - Implement informational response polling in h2 client task with callback invocation - Add server-side informational response forwarding using h2's send_informational API - Include extensive integration tests covering multiple scenarios and edge cases - Add complete working example with TLS, resource preloading, and performance monitoring - Update Cargo.toml with local h2 dependency and example build configuration The implementation enables servers to send resource preload hints before final responses, allowing browsers to start downloading critical resources early and improve page load performance. Clients can register callbacks to process 103 Early Hints and other informational responses. Closes #3980, #2426 --- Cargo.toml | 11 +- examples/README.md | 2 + examples/http2_early_hints.rs | 576 ++++++++++++++ src/client/conn/http2.rs | 58 +- src/client/conn/informational.rs | 171 +++++ src/client/conn/mod.rs | 2 + src/proto/h1/dispatch.rs | 4 +- src/proto/h1/role.rs | 2 +- src/proto/h2/client.rs | 44 ++ src/proto/h2/server.rs | 54 +- tests/integration-early-hints.rs | 1239 ++++++++++++++++++++++++++++++ 11 files changed, 2156 insertions(+), 7 deletions(-) create mode 100644 examples/http2_early_hints.rs create mode 100644 src/client/conn/informational.rs create mode 100644 tests/integration-early-hints.rs diff --git a/Cargo.toml b/Cargo.toml index 4441bdcdea..21a0971ef1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,10 @@ tokio = { version = "1", features = [ ] } tokio-test = "0.4" tokio-util = "0.7.10" +# Additional dependencies for HTTP/2 Early Hints example +rcgen = "0.12" +tokio-rustls = "0.25" +rustls-pemfile = "2.0" [features] # Nothing by default @@ -85,7 +89,7 @@ http2 = ["dep:futures-channel", "dep:futures-core", "dep:h2"] # Client/Server client = ["dep:want", "dep:pin-project-lite", "dep:smallvec"] -server = ["dep:httpdate", "dep:pin-project-lite", "dep:smallvec"] +server = ["dep:httpdate", "dep:pin-project-lite", "dep:smallvec", "dep:futures-util"] # C-API support (currently unstable (no semver)) ffi = ["dep:http-body-util", "dep:futures-util"] @@ -202,6 +206,11 @@ name = "web_api" path = "examples/web_api.rs" required-features = ["full"] +[[example]] +name = "http2_early_hints" +path = "examples/http2_early_hints.rs" +required-features = ["full"] + [[bench]] name = "body" diff --git a/examples/README.md b/examples/README.md index de38911e9c..ad27667916 100644 --- a/examples/README.md +++ b/examples/README.md @@ -38,6 +38,8 @@ futures-util = { version = "0.3", default-features = false } * [`echo`](echo.rs) - An echo server that copies POST request's content to the response content. +* [`http2_early_hints`](http2_early_hints.rs) - An HTTP/2 server that sends 103 Early Hints. + ## Going Further * [`gateway`](gateway.rs) - A server gateway (reverse proxy) that proxies to the `hello` service above. diff --git a/examples/http2_early_hints.rs b/examples/http2_early_hints.rs new file mode 100644 index 0000000000..9b15118e98 --- /dev/null +++ b/examples/http2_early_hints.rs @@ -0,0 +1,576 @@ +//! HTTP/2 server demonstrating 103 Early Hints +//! +//! This example shows the recommended approach: 103 Early Hints. +//! +//! Run with: +//! ``` +//! cargo run --example http2_early_hints --features full +//! ``` + +use std::convert::Infallible; +use std::fs; +use std::net::SocketAddr; +use std::time::Instant; + +use bytes::Bytes; +use http::{Request, Response, StatusCode}; +use http_body_util::Full; +use hyper::body::Incoming as IncomingBody; +use hyper::ext::InformationalSender; +use hyper::server::conn::http2; +use hyper::service::service_fn; +use tokio::net::TcpListener; +use tokio_rustls::rustls::{ + pki_types::{CertificateDer, PrivateKeyDer}, + ServerConfig, +}; +use tokio_rustls::TlsAcceptor; + +#[path = "../benches/support/mod.rs"] +mod support; +use support::{TokioExecutor, TokioIo}; + +/// Load certificates from provided files +fn load_certificates() -> Result< + (Vec>, PrivateKeyDer<'static>), + Box, +> { + // Read certificate file + let cert_pem = fs::read_to_string("/tmp/cert.txt")?; + + // Parse certificate chain + let mut certs = Vec::new(); + for cert in rustls_pemfile::certs(&mut cert_pem.as_bytes()) { + certs.push(cert?); + } + + // Read private key file + let key_pem = fs::read_to_string("/tmp/key.txt")?; + + // Parse private key + let mut key_reader = key_pem.as_bytes(); + let key = + rustls_pemfile::private_key(&mut key_reader)?.ok_or("No private key found in key file")?; + + Ok((certs, key)) +} + +/// Generate a self-signed certificate for testing (fallback) +fn generate_self_signed_cert() -> (Vec>, PrivateKeyDer<'static>) { + use rcgen::{Certificate as RcgenCert, CertificateParams, DistinguishedName}; + + let mut params = CertificateParams::new(vec!["localhost".to_string()]); + params.distinguished_name = DistinguishedName::new(); + + let cert = RcgenCert::from_params(params).unwrap(); + let cert_der = cert.serialize_der().unwrap(); + let private_key_der = cert.serialize_private_key_der(); + + ( + vec![CertificateDer::from(cert_der)], + PrivateKeyDer::try_from(private_key_der).unwrap(), + ) +} + +/// HTTP service demonstrating 103 Early Hints +async fn handle_request( + mut req: Request, +) -> Result>, Infallible> { + let path = req.uri().path(); + println!("Received request: {} {}", req.method(), req.uri()); + + // Handle static resources that we hinted about + match path { + // CSS Resources + "/css/critical.css" => { + let css_content = r#" +/* Critical CSS - Above the fold styling */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + line-height: 1.6; + color: #333; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +.hero { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + color: white; + text-shadow: 2px 2px 4px rgba(0,0,0,0.3); +} + +.hero h1 { + font-size: 4rem; + font-weight: 700; + margin-bottom: 1rem; + animation: fadeInUp 1s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +"#; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/css") + .header("cache-control", "public, max-age=31536000") + .body(Full::new(Bytes::from(css_content))) + .unwrap()); + } + + "/css/layout.css" => { + let css_content = r#" +/* Layout CSS - Page structure and components */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 2rem; +} + +.navbar { + position: fixed; + top: 0; + width: 100%; + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + padding: 1rem 0; + z-index: 1000; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); +} + +.content { + padding: 2rem 0; + background: white; + border-radius: 8px; + margin: 2rem 0; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); +} + +.footer { + background: #333; + color: white; + text-align: center; + padding: 2rem 0; +} + +@media (max-width: 768px) { + .hero h1 { + font-size: 2.5rem; + } + .container { + padding: 0 1rem; + } +} +"#; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/css") + .header("cache-control", "public, max-age=31536000") + .body(Full::new(Bytes::from(css_content))) + .unwrap()); + } + + // JavaScript Resources + "/js/app.js" => { + let js_content = r#" +// Application JavaScript - Core functionality +console.log('103 Early Hints Demo - App JS Loaded'); + +document.addEventListener('DOMContentLoaded', function() { + console.log('DOM loaded, initializing app...'); + + // Simulate app initialization + const loadTime = performance.now(); + console.log(`App initialized in ${loadTime.toFixed(2)}ms`); + + // Add interactive features + const buttons = document.querySelectorAll('button'); + buttons.forEach(button => { + button.addEventListener('click', function() { + console.log('Button clicked:', this.textContent); + }); + }); + + // Performance monitoring + if (window.PerformanceObserver) { + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.initiatorType === 'link' && entry.name.includes('103')) { + console.log('Early Hint resource loaded:', entry.name, `in ${entry.duration}ms`); + } + }); + }); + observer.observe({entryTypes: ['resource']}); + } +}); +"#; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/javascript") + .header("cache-control", "public, max-age=31536000") + .body(Full::new(Bytes::from(js_content))) + .unwrap()); + } + + "/js/vendor.js" => { + let js_content = r#" +// Vendor JavaScript - Third party libraries simulation +console.log('103 Early Hints Demo - Vendor JS Loaded'); + +// Simulate a small utility library +window.EarlyHintsDemo = { + version: '1.0.0', + + formatTime: function(ms) { + return `${ms.toFixed(2)}ms`; + }, + + measureResourceTiming: function() { + const resources = performance.getEntriesByType('resource'); + const hintedResources = resources.filter(r => + r.name.includes('/css/') || + r.name.includes('/js/') || + r.name.includes('/fonts/') || + r.name.includes('/images/') || + r.name.includes('/api/') + ); + + console.group('103 Early Hints Resource Timing'); + hintedResources.forEach(resource => { + console.log(`${resource.name}: ${this.formatTime(resource.duration)}`); + }); + console.groupEnd(); + + return hintedResources; + }, + + init: function() { + console.log('Early Hints Demo Utils initialized'); + + // Measure performance after page load + window.addEventListener('load', () => { + setTimeout(() => this.measureResourceTiming(), 1000); + }); + } +}; + +// Auto-initialize +EarlyHintsDemo.init(); +"#; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/javascript") + .header("cache-control", "public, max-age=31536000") + .body(Full::new(Bytes::from(js_content))) + .unwrap()); + } + + // Font Resources (simulated WOFF2) + "/fonts/main.woff2" | "/fonts/icons.woff2" => { + // In a real app, these would be actual font files + // For demo purposes, return a small binary-like response + let font_simulation = b"WOFF2\x00\x01\x00\x00\x00\x00\x02\x00"; // WOFF2 magic bytes + minimal data + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "font/woff2") + .header("cache-control", "public, max-age=31536000") + .header("access-control-allow-origin", "*") + .body(Full::new(Bytes::from(&font_simulation[..]))) + .unwrap()); + } + + // Image Resource (simulated WebP) + "/images/hero.webp" => { + // Minimal WebP header for simulation + let webp_simulation = b"RIFF\x1A\x00\x00\x00WEBPVP8 \x0E\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "image/webp") + .header("cache-control", "public, max-age=31536000") + .body(Full::new(Bytes::from(&webp_simulation[..]))) + .unwrap()); + } + + // API Resource + "/api/initial-data.json" => { + let json_data = r#"{ + "title": "103 Early Hints Demo", + "version": "1.0.0", + "performance": { + "early_hints_enabled": true, + "resources_hinted": 8 + }, + "resources": [ + {"type": "css", "url": "/css/critical.css", "priority": "high"}, + {"type": "css", "url": "/css/layout.css", "priority": "high"}, + {"type": "js", "url": "/js/app.js", "priority": "high"}, + {"type": "js", "url": "/js/vendor.js", "priority": "medium"}, + {"type": "font", "url": "/fonts/main.woff2", "priority": "medium"}, + {"type": "font", "url": "/fonts/icons.woff2", "priority": "low"}, + {"type": "image", "url": "/images/hero.webp", "priority": "medium"}, + {"type": "json", "url": "/api/initial-data.json", "priority": "low"} + ], + "timestamp": "2024-12-08T19:40:00Z" +}"#; + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .header("access-control-allow-origin", "*") + .header("cache-control", "public, max-age=300") + .body(Full::new(Bytes::from(json_data))) + .unwrap()); + } + + // Root path - serve HTML page with all the hinted resources + "/" => { + // Send 103 Early Hints first + if let Some(mut informational_sender) = + req.extensions_mut().remove::() + { + println!("Sending 103 Early Hints (all critical resources)"); + + let start_time = Instant::now(); + + let hints = Response::builder() + .status(StatusCode::EARLY_HINTS) + // Critical CSS (highest priority - render blocking) + .header("link", "; rel=preload; as=style") + .header("link", "; rel=preload; as=style") + // Critical JavaScript (high priority - interaction) + .header("link", "; rel=preload; as=script") + .header("link", "; rel=preload; as=script") + // Fonts (medium priority - text rendering) + .header( + "link", + "; rel=preload; as=font; crossorigin", + ) + .header( + "link", + "; rel=preload; as=font; crossorigin", + ) + // Hero image (medium priority - above fold) + .header("link", "; rel=preload; as=image") + // API data (lower priority - dynamic content) + .header( + "link", + "; rel=preload; as=fetch; crossorigin", + ) + // Metadata for tracking + .header("x-resource-count", "8") + .header("x-priority-order", "css,js,fonts,images,api") + .body(()) + .unwrap(); + + if let Err(e) = informational_sender.0.try_send(hints) { + eprintln!("Failed to send hints: {}", e); + } else { + let send_duration = start_time.elapsed(); + println!("103 Early Hints sent in: {:?}", send_duration); + println!(" 8 resources hinted in single response"); + println!(" Browser processes once, starts all preloads immediately"); + } + + // Simulate realistic server processing time + println!("Processing request (simulating database queries, template rendering...)"); + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + } + + let html_content = r#" + + + + + 103 Early Hints Demo - Hyper HTTP/2 Server + + + + + + + + + + + + +
+
+

HTTP/2 Early Hints

+

Demonstrating 103 Early Hints with Hyper

+ +
+ + Hero +
+ +
+
+

Resource Loading Analysis

+

This page demonstrates 103 Early Hints by preloading 8 critical resources:

+
    +
  • CSS: critical.css, layout.css
  • +
  • JavaScript: app.js, vendor.js
  • +
  • Fonts: main.woff2, icons.woff2
  • +
  • Images: hero.webp
  • +
  • API Data: initial-data.json
  • +
+ +

Performance Benefits

+

With 103 Early Hints, the browser can start downloading critical resources + while the server is still processing the main request, reducing overall page load time.

+ +
Loading API data...
+
+
+ +
+
+

© 2024 Hyper HTTP/2 Early Hints Demo

+
+
+ + + + + + + + +"#; + + return Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html; charset=utf-8") + .header("x-server", "hyper-103") + .header("x-total-resources-hinted", "8") + .body(Full::new(Bytes::from(html_content))) + .unwrap()); + } + + // Default 404 handler + _ => { + let not_found_html = format!( + r#" + + + 404 Not Found + + + +

404 Not Found

+

The requested resource {} was not found.

+

← Back to Demo

+ +"#, + path + ); + + return Ok(Response::builder() + .status(StatusCode::NOT_FOUND) + .header("content-type", "text/html") + .body(Full::new(Bytes::from(not_found_html))) + .unwrap()); + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Initialize logging + pretty_env_logger::init(); + + let addr: SocketAddr = ([0, 0, 0, 0], 3000).into(); + + // Load provided certificates or fallback to self-signed + let (certs, key) = match load_certificates() { + Ok((certs, key)) => { + println!("Loaded certificates from /tmp/cert.txt and /tmp/key.txt"); + (certs, key) + } + Err(e) => { + println!( + "Failed to load provided certificates ({}), generating self-signed certificate...", + e + ); + generate_self_signed_cert() + } + }; + + // Configure TLS + let mut config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key)?; + + // Enable HTTP/2 + config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + + let tls_acceptor = TlsAcceptor::from(std::sync::Arc::new(config)); + + // Create TCP listener + let listener = TcpListener::bind(addr).await?; + println!("103 Early Hints Server listening on https://{}", addr); + println!("Test: curl -k --http2 -v https://localhost:3000/"); + println!("Expected: 1 103 response + 1 final 200 response"); + println!("Benefits: Minimal browser overhead, maximum performance"); + + loop { + let (tcp_stream, _) = listener.accept().await?; + let tls_acceptor = tls_acceptor.clone(); + + tokio::spawn(async move { + // Perform TLS handshake + let tls_stream = match tls_acceptor.accept(tcp_stream).await { + Ok(stream) => stream, + Err(e) => { + eprintln!("TLS handshake failed: {}", e); + return; + } + }; + + // Serve HTTP/2 connection + let service = service_fn(handle_request); + + if let Err(e) = http2::Builder::new(TokioExecutor) + .serve_connection(TokioIo::new(tls_stream), service) + .await + { + eprintln!("HTTP/2 connection error: {}", e); + } + }); + } +} diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index 0efaabe41e..ddc6a81bca 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -14,6 +14,7 @@ use futures_core::ready; use http::{Request, Response}; use super::super::dispatch::{self, TrySendError}; +use super::informational::InformationalConfig; use crate::body::{Body, Incoming as IncomingBody}; use crate::common::time::Time; use crate::proto; @@ -61,6 +62,7 @@ pub struct Builder { pub(super) exec: Ex, pub(super) timer: Time, h2_builder: proto::h2::client::Config, + informational_config: InformationalConfig, } /// Returns a handshake future over some IO. @@ -263,6 +265,7 @@ where exec, timer: Time::Empty, h2_builder: Default::default(), + informational_config: InformationalConfig::new(), } } @@ -465,6 +468,50 @@ where self } + /// Configures handling of informational responses (1xx status codes). + /// + /// By default, informational responses are ignored. This method allows you to + /// provide a callback that will be invoked whenever an informational response + /// is received, such as 103 Early Hints. + /// + /// # Examples + /// + /// ```rust + /// use hyper::client::conn::http2::Builder; + /// use hyper::client::conn::informational::InformationalConfig; + /// use http::StatusCode; + /// + /// #[derive(Clone)] + /// struct TokioExecutor; + /// + /// impl hyper::rt::Executor for TokioExecutor + /// where + /// F: std::future::Future + Send + 'static, + /// F::Output: Send + 'static, + /// { + /// fn execute(&self, fut: F) { + /// tokio::task::spawn(fut); + /// } + /// } + /// + /// let mut builder = Builder::new(TokioExecutor); + /// builder.informational_responses( + /// InformationalConfig::new().with_callback(|response| { + /// if response.status() == StatusCode::EARLY_HINTS { + /// println!("Received 103 Early Hints"); + /// // Process Link headers for resource preloading + /// for link in response.headers().get_all("link") { + /// println!("Preload: {:?}", link); + /// } + /// } + /// }) + /// ); + /// ``` + pub fn informational_responses(&mut self, config: InformationalConfig) -> &mut Self { + self.informational_config = config; + self + } + /// Constructs a connection with the configured options and IO. /// See [`client::conn`](crate::client::conn) for more. /// @@ -487,8 +534,15 @@ where trace!("client handshake HTTP/2"); let (tx, rx) = dispatch::channel(); - let h2 = proto::h2::client::handshake(io, rx, &opts.h2_builder, opts.exec, opts.timer) - .await?; + let h2 = proto::h2::client::handshake( + io, + rx, + &opts.h2_builder, + opts.exec, + opts.timer, + Some(opts.informational_config.clone()), + ) + .await?; Ok(( SendRequest { dispatch: tx.unbound(), diff --git a/src/client/conn/informational.rs b/src/client/conn/informational.rs new file mode 100644 index 0000000000..e308393815 --- /dev/null +++ b/src/client/conn/informational.rs @@ -0,0 +1,171 @@ +//! Informational response handling for HTTP/2 client connections. +//! +//! This module provides callback-based handling of 1xx informational responses, +//! including 103 Early Hints, for HTTP/2 client connections. + +use http::Response; +use std::fmt; +use std::sync::Arc; + +/// A callback function for handling informational responses (1xx status codes). +/// +/// This callback is invoked whenever the client receives an informational response +/// from the server, such as 103 Early Hints. The callback receives the complete +/// informational response including headers. +/// +/// # Examples +/// +/// ```rust +/// use hyper::client::conn::informational::InformationalCallback; +/// use http::{Response, StatusCode}; +/// use std::sync::Arc; +/// +/// let callback: InformationalCallback = Arc::new(|response: Response<()>| { +/// if response.status() == StatusCode::EARLY_HINTS { +/// println!("Received 103 Early Hints with {} headers", +/// response.headers().len()); +/// // Process Link headers for resource preloading +/// for link in response.headers().get_all("link") { +/// println!("Preload: {:?}", link); +/// } +/// } +/// }); +/// ``` +pub type InformationalCallback = Arc) + Send + Sync>; + +/// Configuration for informational response handling. +/// +/// This struct allows configuring how informational responses should be handled +/// by the HTTP/2 client connection. +#[derive(Default)] +pub struct InformationalConfig { + /// Optional callback for handling informational responses. + /// If None, informational responses are ignored (current behavior). + pub callback: Option, +} + +impl InformationalConfig { + /// Creates a new informational configuration with no callback. + pub fn new() -> Self { + Self::default() + } + + /// Sets the callback for handling informational responses. + pub fn with_callback(mut self, callback: F) -> Self + where + F: Fn(Response<()>) + Send + Sync + 'static, + { + self.callback = Some(Arc::new(callback)); + self + } + + /// Returns true if a callback is configured. + pub fn has_callback(&self) -> bool { + self.callback.is_some() + } + + /// Invokes the callback if one is configured. + /// + /// This is a test helper method - in production code, the callback + /// is extracted and called directly for better performance. + #[cfg(test)] + pub(crate) fn invoke_callback(&self, response: Response<()>) { + if let Some(ref callback) = self.callback { + callback(response); + } + } +} + +impl fmt::Debug for InformationalConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("InformationalConfig") + .field("has_callback", &self.has_callback()) + .finish() + } +} + +impl Clone for InformationalConfig { + fn clone(&self) -> Self { + // Arc allows us to clone the callback + Self { + callback: self.callback.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http::StatusCode; + use std::sync::{Arc, Mutex}; + + #[test] + fn test_informational_config_creation() { + let config = InformationalConfig::new(); + assert!(!config.has_callback()); + } + + #[test] + fn test_informational_config_with_callback() { + let called = Arc::new(Mutex::new(false)); + let called_clone = called.clone(); + + let config = InformationalConfig::new().with_callback(move |_response| { + *called_clone.lock().unwrap() = true; + }); + + assert!(config.has_callback()); + + // Test callback invocation + let mut response = Response::new(()); + *response.status_mut() = StatusCode::EARLY_HINTS; + config.invoke_callback(response); + + assert!(*called.lock().unwrap()); + } + + #[test] + fn test_informational_config_clone() { + let config = InformationalConfig::new().with_callback(|_| {}); + assert!(config.has_callback()); + + let cloned = config.clone(); + assert!(cloned.has_callback()); // Callback is cloned with Arc + } + + #[test] + fn test_early_hints_callback() { + let received_links = Arc::new(Mutex::new(Vec::new())); + let received_links_clone = received_links.clone(); + + let config = InformationalConfig::new().with_callback(move |response| { + if response.status() == StatusCode::EARLY_HINTS { + for link in response.headers().get_all("link") { + received_links_clone + .lock() + .unwrap() + .push(link.to_str().unwrap().to_string()); + } + } + }); + + // Simulate 103 Early Hints response + let mut response = Response::new(()); + *response.status_mut() = StatusCode::EARLY_HINTS; + response.headers_mut().insert( + "link", + "; rel=preload; as=style".parse().unwrap(), + ); + response.headers_mut().append( + "link", + "; rel=preload; as=script".parse().unwrap(), + ); + + config.invoke_callback(response); + + let links = received_links.lock().unwrap(); + assert_eq!(links.len(), 2); + assert!(links.contains(&"; rel=preload; as=style".to_string())); + assert!(links.contains(&"; rel=preload; as=script".to_string())); + } +} diff --git a/src/client/conn/mod.rs b/src/client/conn/mod.rs index f982ae6ddb..24e0764c28 100644 --- a/src/client/conn/mod.rs +++ b/src/client/conn/mod.rs @@ -18,5 +18,7 @@ pub mod http1; #[cfg(feature = "http2")] pub mod http2; +#[cfg(feature = "http2")] +pub mod informational; pub use super::dispatch::TrySendError; diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index d852e55e6d..57920e9e63 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -10,9 +10,9 @@ use crate::rt::{Read, Write}; use bytes::{Buf, Bytes}; #[cfg(feature = "server")] use futures_channel::mpsc::{self, Receiver}; -use futures_util::ready; +use futures_core::ready; #[cfg(feature = "server")] -use futures_util::StreamExt; +use futures_util::stream::StreamExt; use http::Request; #[cfg(feature = "server")] use http::Response; diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index ab062599f7..ba4b1cc2c3 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -2572,7 +2572,7 @@ mod tests { ) .unwrap(); - assert!(encoder.is_last()); + assert!(encoder.expect("encoder should exist").is_last()); } #[cfg(feature = "server")] diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index 455c70980c..147c1ea3fb 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -145,6 +145,7 @@ pub(crate) async fn handshake( config: &Config, mut exec: E, timer: Time, + informational_config: Option, ) -> crate::Result> where T: Read + Write + Unpin, @@ -193,6 +194,7 @@ where h2_tx, req_rx, fut_ctx: None, + informational_callback: informational_config.and_then(|config| config.callback), marker: PhantomData, }) } @@ -410,6 +412,7 @@ where body_tx: SendStream>, body: B, cb: Callback, Response>, + informational_callback: Option, } impl Unpin for FutCtx {} @@ -426,6 +429,7 @@ where h2_tx: SendRequest>, req_rx: ClientRx, fut_ctx: Option>, + informational_callback: Option, marker: PhantomData, } @@ -530,6 +534,7 @@ where ping: Some(ping), send_stream: Some(send_stream), exec: self.executor.clone(), + informational_callback: f.informational_callback, }, call_back: Some(f.cb), }, @@ -549,6 +554,7 @@ pin_project! { #[pin] send_stream: Option::Data>>>>, exec: E, + informational_callback: Option, } } @@ -562,6 +568,39 @@ where fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let mut this = self.as_mut().project(); + // First, check for any informational responses and invoke the callback if present + if let Some(callback) = &this.informational_callback { + let mut processed_informational = false; + loop { + match this.fut.poll_informational(cx) { + Poll::Ready(Some(Ok(informational_response))) => { + // Invoke the callback with the informational response + callback(informational_response); + processed_informational = true; + // Continue polling for more informational responses + continue; + } + Poll::Ready(Some(Err(_err))) => { + // Error in informational response, log it but don't fail the main response + debug!("informational response error: {}", _err); + break; + } + Poll::Ready(None) => { + // No more informational responses expected + break; + } + Poll::Pending => { + // If we processed any informational responses, return Pending to allow + // the H2 layer to process them before polling the main response + if processed_informational { + return Poll::Pending; + } + break; + } + } + } + } + let result = ready!(this.fut.poll(cx)); let ping = this.ping.take().expect("Future polled twice"); @@ -654,6 +693,10 @@ where continue; } let (head, body) = req.into_parts(); + + // Use the connection-level informational callback + let informational_callback = self.informational_callback.clone(); + let mut req = ::http::Request::from_parts(head, ()); super::strip_connection_headers(req.headers_mut(), true); if let Some(len) = body.size_hint().exact() { @@ -700,6 +743,7 @@ where body_tx, body, cb, + informational_callback, }; // Check poll_ready() again. diff --git a/src/proto/h2/server.rs b/src/proto/h2/server.rs index 483ed96dd9..dece76785e 100644 --- a/src/proto/h2/server.rs +++ b/src/proto/h2/server.rs @@ -11,11 +11,20 @@ use h2::{Reason, RecvStream}; use http::{Method, Request}; use pin_project_lite::pin_project; +#[cfg(feature = "server")] +use futures_channel::mpsc::{self, Receiver}; +#[cfg(feature = "server")] +use futures_util::stream::StreamExt; +#[cfg(feature = "server")] +use http::Response; + use super::{ping, PipeToSendStream, SendBuf}; use crate::body::{Body, Incoming as IncomingBody}; use crate::common::date; use crate::common::io::Compat; use crate::common::time::Time; +#[cfg(feature = "server")] +use crate::ext::InformationalSender; use crate::ext::Protocol; use crate::headers; use crate::proto::h2::ping::Recorder; @@ -25,7 +34,6 @@ use crate::rt::{Read, Write}; use crate::service::HttpService; use crate::upgrade::{OnUpgrade, Pending, Upgraded}; -use crate::Response; // Our defaults are chosen for the "majority" case, which usually are not // resource constrained, and so the spec default of 64kb can be too limiting @@ -302,12 +310,24 @@ where req.extensions_mut().insert(Protocol::from_inner(protocol)); } + #[cfg(feature = "server")] + let (informational_tx, informational_rx) = mpsc::channel(10); + #[cfg(feature = "server")] + { + req.extensions_mut() + .insert(InformationalSender(informational_tx)); + } + let fut = H2Stream::new( service.call(req), connect_parts, respond, self.date_header, exec.clone(), + #[cfg(feature = "server")] + Some(informational_rx), + #[cfg(not(feature = "server"))] + None, ); exec.execute_h2stream(fut); @@ -366,6 +386,7 @@ pin_project! { state: H2StreamState, date_header: bool, exec: E, + informational_rx: Option>>, } } @@ -403,12 +424,14 @@ where respond: SendResponse>, date_header: bool, exec: E, + informational_rx: Option>>, ) -> H2Stream { H2Stream { reply: respond, state: H2StreamState::Service { fut, connect_parts }, date_header, exec, + informational_rx, } } } @@ -438,6 +461,35 @@ where fn poll2(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let mut me = self.as_mut().project(); loop { + // Check for informational responses first + #[cfg(feature = "server")] + if let Some(informational_rx) = me.informational_rx.as_mut() { + // Send informational responses using the new h2 API + while let Poll::Ready(informational) = informational_rx.poll_next_unpin(cx) { + match informational { + Some(informational) => { + trace!( + "Received informational response: {:?}", + informational.status() + ); + // Send the informational response using the new h2 API + if let Err(e) = me.reply.send_informational(informational) { + debug!("Failed to send informational response: {}", e); + return Poll::Ready(Err(crate::Error::new_h2(e))); + } else { + trace!("Successfully sent informational response"); + } + } + None => { + trace!("Informational channel closed"); + // Channel closed, remove it + *me.informational_rx = None; + break; + } + } + } + } + let next = match me.state.as_mut().project() { H2StreamStateProj::Service { fut: h, diff --git a/tests/integration-early-hints.rs b/tests/integration-early-hints.rs new file mode 100644 index 0000000000..d27ddee76d --- /dev/null +++ b/tests/integration-early-hints.rs @@ -0,0 +1,1239 @@ +#![deny(warnings)] +#![cfg(feature = "http2")] + +//! Integration tests for HTTP/2 103 Early Hints support +//! +//! These tests validate the complete 103 Early Hints implementation according to: +//! - RFC 8297: An HTTP Status Code for Indicating Hints +//! - MDN Web Docs: 103 Early Hints specification +//! - Real browser behavior and security requirements + +use bytes::Bytes; +use futures_util::SinkExt; +use http_body_util::Full; +use hyper::client::conn::http2::Builder; +use hyper::client::conn::informational::InformationalConfig; +use hyper::server::conn::http2::Builder as ServerBuilder; +use hyper::service::service_fn; +use hyper::{Request, Response, StatusCode}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use tokio::net::{TcpListener, TcpStream}; + +// Re-use support module from integration tests +#[path = "support/mod.rs"] +mod support; +use support::{TokioExecutor, TokioIo}; + +// ============================================================================ +// Test Abstractions and Helper Structures +// ============================================================================ + +/// Helper struct to track informational responses received by client +#[derive(Debug, Clone)] +struct InformationalResponse { + status: u16, + #[allow(dead_code)] + headers: HashMap, + timestamp: std::time::Instant, +} + +/// Builder for creating 103 Early Hints responses with fluent API +#[derive(Debug, Clone)] +struct EarlyHintsBuilder { + headers: Vec<(String, String)>, + processing_stage: Option, + delay_ms: u64, +} + +impl EarlyHintsBuilder { + fn new() -> Self { + Self { + headers: Vec::new(), + processing_stage: None, + delay_ms: 50, + } + } + + fn link_preload_css(mut self, url: &str) -> Self { + self.headers.push(( + "link".to_string(), + format!("<{}>; rel=preload; as=style", url), + )); + self + } + + fn link_preload_js(mut self, url: &str) -> Self { + self.headers.push(( + "link".to_string(), + format!("<{}>; rel=preload; as=script", url), + )); + self + } + + fn link_preload_font(mut self, url: &str, crossorigin: bool) -> Self { + let co = if crossorigin { "; crossorigin" } else { "" }; + self.headers.push(( + "link".to_string(), + format!("<{}>; rel=preload; as=font{}", url, co), + )); + self + } + + fn link_preload_image(mut self, url: &str) -> Self { + self.headers.push(( + "link".to_string(), + format!("<{}>; rel=preload; as=image", url), + )); + self + } + + fn link_preload_fetch(mut self, url: &str, crossorigin: bool) -> Self { + let co = if crossorigin { "; crossorigin" } else { "" }; + self.headers.push(( + "link".to_string(), + format!("<{}>; rel=preload; as=fetch{}", url, co), + )); + self + } + + fn link_preconnect(mut self, url: &str, crossorigin: bool) -> Self { + let co = if crossorigin { "; crossorigin" } else { "" }; + self.headers.push(( + "link".to_string(), + format!("<{}>; rel=preconnect{}", url, co), + )); + self + } + + fn csp(mut self, policy: &str) -> Self { + self.headers + .push(("content-security-policy".to_string(), policy.to_string())); + self + } + + fn processing_stage(mut self, stage: &str) -> Self { + self.processing_stage = Some(stage.to_string()); + self + } + + fn delay(mut self, ms: u64) -> Self { + self.delay_ms = ms; + self + } + + fn custom_header(mut self, key: &str, value: &str) -> Self { + self.headers.push((key.to_string(), value.to_string())); + self + } + + async fn send_via( + self, + informational_sender: &mut hyper::ext::InformationalSender, + ) -> Result<(), Box> { + let mut response_builder = Response::builder().status(StatusCode::EARLY_HINTS); + + for (key, value) in &self.headers { + response_builder = response_builder.header(key, value); + } + + if let Some(stage) = &self.processing_stage { + response_builder = response_builder.header("x-processing-stage", stage); + } + + let early_hints_response = response_builder.body(())?; + + if let Err(e) = informational_sender.0.send(early_hints_response).await { + eprintln!("Server: Failed to send 103 Early Hints response: {}", e); + return Err(Box::new(e)); + } else { + println!("Server: Successfully sent 103 Early Hints response"); + } + + if self.delay_ms > 0 { + tokio::time::sleep(tokio::time::Duration::from_millis(self.delay_ms)).await; + } + + Ok(()) + } +} + +/// Test server builder for Early Hints scenarios +struct EarlyHintsTestServer { + addr: std::net::SocketAddr, + handle: tokio::task::JoinHandle<()>, +} + +impl EarlyHintsTestServer { + async fn with_early_hints(early_hints_fn: F, final_response_fn: H) -> Self + where + F: Fn() -> Vec + Send + Sync + 'static + Clone, + H: Fn() -> Response> + Send + Sync + 'static + Clone, + { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let io = TokioIo::new(stream); + + let service = service_fn(move |mut req| { + let early_hints_fn = early_hints_fn.clone(); + let final_response_fn = final_response_fn.clone(); + + async move { + // Send Early Hints if informational sender is available + if let Some(mut informational_sender) = + req.extensions_mut() + .remove::() + { + let hints = early_hints_fn(); + for hint in hints { + if let Err(e) = hint.send_via(&mut informational_sender).await { + eprintln!("Failed to send early hint: {}", e); + } + } + } + + Ok::<_, hyper::Error>(final_response_fn()) + } + }); + + ServerBuilder::new(TokioExecutor) + .serve_connection(io, service) + .await + .unwrap(); + }); + + Self { + addr, + handle: server_handle, + } + } + + fn addr(&self) -> std::net::SocketAddr { + self.addr + } + + fn abort(self) { + self.handle.abort(); + } +} + +/// Assertion helper for Early Hints responses +struct EarlyHintsAssertions<'a> { + responses: &'a [InformationalResponse], + current_index: usize, +} + +impl<'a> EarlyHintsAssertions<'a> { + fn new(responses: &'a [InformationalResponse]) -> Self { + Self { + responses, + current_index: 0, + } + } + + fn expect_count(self, count: usize) -> Self { + assert_eq!( + self.responses.len(), + count, + "Expected {} Early Hints responses, got {}", + count, + self.responses.len() + ); + self + } + + fn expect_single_103_response(self) -> Self { + self.expect_count(1) + .expect_status(StatusCode::EARLY_HINTS.as_u16()) + } + + fn expect_status(self, status: u16) -> Self { + if self.current_index < self.responses.len() { + assert_eq!( + self.responses[self.current_index].status, status, + "Expected status {}, got {}", + status, self.responses[self.current_index].status + ); + } + self + } + + fn expect_link_contains(self, content: &str) -> Self { + if self.current_index < self.responses.len() { + let headers = &self.responses[self.current_index].headers; + let all_header_values: Vec = headers.values().cloned().collect(); + let combined_headers = all_header_values.join(" "); + assert!( + combined_headers.contains(content), + "Expected link headers to contain '{}', got: {}", + content, + combined_headers + ); + } + self + } + + fn expect_header(self, key: &str, value: &str) -> Self { + if self.current_index < self.responses.len() { + let headers = &self.responses[self.current_index].headers; + assert!( + headers.contains_key(key), + "Expected header '{}' to be present", + key + ); + assert_eq!( + headers.get(key).unwrap(), + value, + "Expected header '{}' to have value '{}', got '{}'", + key, + value, + headers.get(key).unwrap() + ); + } + self + } + + fn expect_processing_stage(self, stage: &str) -> Self { + self.expect_header("x-processing-stage", stage) + } + + fn expect_has_link_headers(self) -> Self { + if self.current_index < self.responses.len() { + let headers = &self.responses[self.current_index].headers; + assert!( + headers.contains_key("link"), + "Expected response to contain Link headers" + ); + } + self + } + + fn expect_crossorigin_present(self) -> Self { + if self.current_index < self.responses.len() { + let headers = &self.responses[self.current_index].headers; + let all_header_values: Vec = headers.values().cloned().collect(); + let combined_headers = all_header_values.join(" "); + assert!( + combined_headers.contains("crossorigin"), + "Expected crossorigin attribute to be present" + ); + } + self + } + + fn next_response(mut self) -> Self { + self.current_index += 1; + self + } + + fn response(mut self, index: usize) -> Self { + self.current_index = index; + self + } +} + +/// Test scenario builder for Early Hints testing +struct EarlyHintsTestScenario { + name: String, + early_hints: Vec, + final_response: Option>>, +} + +impl EarlyHintsTestScenario { + fn new(name: &str) -> Self { + Self { + name: name.to_string(), + early_hints: Vec::new(), + final_response: None, + } + } + + fn with_early_hint(mut self, hint: EarlyHintsBuilder) -> Self { + self.early_hints.push(hint); + self + } + + fn with_final_response(mut self, response: Response>) -> Self { + self.final_response = Some(response); + self + } + + fn with_html_response(self, content: &str) -> Self { + let response = Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .body(Full::new(Bytes::from(content.to_string()))) + .unwrap(); + self.with_final_response(response) + } + + async fn run(self, assertions: F) + where + F: FnOnce(EarlyHintsAssertions, &Response), + { + let _ = pretty_env_logger::try_init(); + + let early_hints = self.early_hints.clone(); + let final_response = self.final_response.unwrap_or_else(|| { + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .body(Full::new(Bytes::from("Test response"))) + .unwrap() + }); + + let server = EarlyHintsTestServer::with_early_hints( + move || early_hints.clone(), + move || final_response.clone(), + ) + .await; + + let (mut sender, _conn_handle, received_responses) = + create_early_hints_client(server.addr()).await; + + let req = Request::builder() + .uri("/") + .body(Full::new(Bytes::new())) + .unwrap(); + + let response = sender.send_request(req).await.unwrap(); + + let responses = received_responses.lock().unwrap(); + println!( + "{} test - received {} informational responses", + self.name, + responses.len() + ); + + let assertions_helper = EarlyHintsAssertions::new(&responses); + assertions(assertions_helper, &response); + + println!("{} test passed", self.name); + server.abort(); + } +} + +/// Utility functions for common response patterns +struct ResponseTemplates; + +impl ResponseTemplates { + fn redirect_response(location: &str) -> Response> { + Response::builder() + .status(StatusCode::MOVED_PERMANENTLY) + .header("location", location) + .body(Full::new(Bytes::from("Redirecting..."))) + .unwrap() + } +} + +/// Helper to create a client with informational response tracking +async fn create_early_hints_client( + addr: std::net::SocketAddr, +) -> ( + hyper::client::conn::http2::SendRequest>, + tokio::task::JoinHandle<()>, + Arc>>, +) { + let received_responses = Arc::new(Mutex::new(Vec::new())); + let responses_clone = received_responses.clone(); + + let config = InformationalConfig::new().with_callback(move |response: Response<()>| { + let mut responses = responses_clone.lock().unwrap(); + let headers: HashMap = response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + + responses.push(InformationalResponse { + status: response.status().as_u16(), + headers, + timestamp: std::time::Instant::now(), + }); + }); + + let stream = TcpStream::connect(addr).await.unwrap(); + let io = TokioIo::new(stream); + + let (sender, conn) = Builder::new(TokioExecutor) + .informational_responses(config) + .handshake(io) + .await + .unwrap(); + + let conn_handle = tokio::spawn(async move { + if let Err(err) = conn.await { + eprintln!("Connection error: {:?}", err); + } + }); + + (sender, conn_handle, received_responses) +} + +/// Helper function to validate Link header syntax +#[allow(dead_code)] +fn validate_link_header(link_header: &str) -> bool { + // Basic Link header validation + // Format: ; rel=relationship; [additional parameters] + link_header.starts_with('<') && link_header.contains('>') && link_header.contains("rel=") +} + +/// Helper function to parse Link header into components +#[allow(dead_code)] +fn parse_link_header(link_header: &str) -> Option<(String, String, HashMap)> { + // Parse Link header: ; rel=relationship; param=value + // This is a simplified parser for testing purposes + + if !validate_link_header(link_header) { + return None; + } + + // Extract URL between < and > + let url_start = link_header.find('<')?; + let url_end = link_header.find('>')?; + let url = link_header[url_start + 1..url_end].to_string(); + + // Extract rel parameter + let rel_start = link_header.find("rel=")?; + let rel_value_start = rel_start + 4; + let rel_end = link_header[rel_value_start..] + .find(';') + .map(|i| rel_value_start + i) + .unwrap_or(link_header.len()); + let rel = link_header[rel_value_start..rel_end].trim().to_string(); + + // Extract additional parameters + let mut params = HashMap::new(); + let params_part = &link_header[url_end + 1..]; + for param in params_part.split(';') { + if let Some(eq_pos) = param.find('=') { + let key = param[..eq_pos].trim().to_string(); + let value = param[eq_pos + 1..].trim().to_string(); + if !key.is_empty() && key != "rel" { + params.insert(key, value); + } + } + } + + Some((url, rel, params)) +} + +// ============================================================================ +// Integration Tests for HTTP/2 103 Early Hints +// ============================================================================ + +/// Test 1: Basic preconnect hints functionality +/// +/// Validates that 103 Early Hints can send preconnect directives to establish +/// early connections to external domains. Tests both regular and crossorigin +/// preconnect scenarios commonly used for CDNs and font providers. +#[tokio::test] +async fn test_103_preconnect_hints() { + EarlyHintsTestScenario::new("preconnect_hints") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preconnect("https://cdn.example.com", false) + .link_preconnect("https://fonts.googleapis.com", true) + .processing_stage("early-hints"), + ) + .with_html_response( + r#" + + + + + + Page with preconnect hints + "#, + ) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_has_link_headers() + .expect_processing_stage("early-hints") + .expect_link_contains("rel=preconnect"); + + assert_eq!(response.status(), StatusCode::OK); + }) + .await; +} + +/// Test 2: Resource preloading with 103 Early Hints +/// +/// Tests the core preload functionality where critical CSS resources are +/// hinted before the final response. This is the most common use case for +/// 103 Early Hints in production web applications. +#[tokio::test] +async fn test_103_preload_hints() { + EarlyHintsTestScenario::new("preload_hints") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/style.css") + .processing_stage("early-hints"), + ) + .with_html_response( + r#" + + + + + + Page with preload hints + "#, + ) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_has_link_headers() + .expect_processing_stage("early-hints") + .expect_link_contains("style.css") + .expect_link_contains("rel=preload"); + + assert_eq!(response.status(), StatusCode::OK); + }) + .await; +} + +/// Test 3: Content Security Policy enforcement via 103 Early Hints +/// +/// Validates that CSP headers can be sent in 103 responses to provide early +/// security policy enforcement. This allows browsers to start applying security +/// policies before the main response arrives. +#[tokio::test] +async fn test_103_with_csp_enforcement() { + EarlyHintsTestScenario::new("csp_enforcement") + .with_early_hint( + EarlyHintsBuilder::new() + .csp("default-src 'self'") + .link_preload_css("/style.css") + .link_preload_js("/script.js") + .processing_stage("csp-enforcement"), + ) + .with_final_response( + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .header("content-security-policy", "default-src 'self'") + .body(Full::new(Bytes::from("CSP enforcement test"))) + .unwrap(), + ) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_header("content-security-policy", "default-src 'self'") + .expect_has_link_headers() + .expect_processing_stage("csp-enforcement"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-security-policy").unwrap(), + "default-src 'self'" + ); + }) + .await; +} + +/// Test 4: Multiple sequential 103 Early Hints responses +/// +/// Tests the ability to send multiple 103 responses in sequence, each with +/// different priorities and processing stages. This simulates complex server +/// processing where hints are sent as resources become available. +#[tokio::test] +async fn test_multiple_103_responses_sequence() { + EarlyHintsTestScenario::new("multiple_103_sequence") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preconnect("https://cdn.example.com", false) + .processing_stage("multiple-103-1") + .custom_header("x-priority", "high") + .delay(25), + ) + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/style.css") + .processing_stage("multiple-103-2") + .custom_header("x-priority", "medium") + .delay(25), + ) + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_js("/script.js") + .processing_stage("multiple-103-3") + .custom_header("x-priority", "low") + .delay(50), + ) + .with_html_response("Multiple 103 responses test") + .run(|assertions, response| { + assertions + .expect_count(3) + .response(0) + .expect_status(StatusCode::EARLY_HINTS.as_u16()) + .expect_processing_stage("multiple-103-1") + .expect_header("x-priority", "high") + .next_response() + .expect_status(StatusCode::EARLY_HINTS.as_u16()) + .expect_processing_stage("multiple-103-2") + .expect_header("x-priority", "medium") + .next_response() + .expect_status(StatusCode::EARLY_HINTS.as_u16()) + .expect_processing_stage("multiple-103-3") + .expect_header("x-priority", "low"); + + assert_eq!(response.status(), StatusCode::OK); + }) + .await; +} + +/// Test 5: Resource type preloading +/// +/// Tests preloading of various resource types (CSS, JS, fonts, images, fetch) +/// with proper crossorigin handling. Validates that all major web resource +/// types can be effectively hinted via 103 Early Hints. +#[tokio::test] +async fn test_103_resource_types() { + EarlyHintsTestScenario::new("resource_types") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/styles/main.css") + .link_preload_js("/scripts/app.js") + .link_preload_font("/fonts/roboto.woff2", true) + .link_preload_image("/images/hero.jpg") + .link_preload_fetch("/data/config.json", true) + .processing_stage("resource-types") + .custom_header("x-resource-count", "5") + ) + .with_html_response(r#" + + + Resource Types Test + + + + +

Resource Types Validation

+ Hero Image + + + "#) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_processing_stage("resource-types") + .expect_header("x-resource-count", "5") + .expect_has_link_headers() + .expect_crossorigin_present(); + + assert_eq!(response.status(), StatusCode::OK); + }) + .await; +} + +/// Test 6: Mixed Link header types in single 103 response +/// +/// Tests HTTP/2 header compression behavior when multiple Link headers with +/// different relationship types are sent. Validates that at least one Link +/// header is properly delivered despite potential compression. +#[tokio::test] +async fn test_103_mixed_link_headers() { + let _ = pretty_env_logger::try_init(); + + // Create a custom server for this test that sends mixed Link header types + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let server_handle = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let io = TokioIo::new(stream); + + let service = service_fn(move |mut req| { + async move { + // Send 103 Early Hints with mixed Link header types + if let Some(mut informational_sender) = req + .extensions_mut() + .remove::() + { + println!("Server: Sending 103 Early Hints with mixed Link headers"); + let early_hints_response = Response::builder() + .status(StatusCode::EARLY_HINTS) // 103 Early Hints + .header("link", "; rel=preconnect") + .header("link", "; rel=preload; as=style") + .header("link", "; rel=preload; as=font; crossorigin") + .header("x-processing-stage", "mixed-links") + .body(()) + .unwrap(); + + if let Err(e) = informational_sender.0.send(early_hints_response).await { + eprintln!("Server: Failed to send 103 Early Hints response: {}", e); + } else { + println!("Server: Successfully sent 103 Early Hints with mixed links"); + } + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + + Ok::<_, hyper::Error>( + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .body(Full::new(Bytes::from("Mixed link types test"))) + .unwrap(), + ) + } + }); + + ServerBuilder::new(TokioExecutor) + .serve_connection(io, service) + .await + .unwrap(); + }); + + let (mut sender, _conn_handle, received_responses) = create_early_hints_client(addr).await; + + let req = Request::builder() + .uri("/mixed-links") + .body(Full::new(Bytes::new())) + .unwrap(); + + let response = sender.send_request(req).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + // Verify 103 response with mixed Link header types + let responses = received_responses.lock().unwrap(); + println!( + "Mixed links test - received {} informational responses", + responses.len() + ); + + // Should have received exactly one 103 Early Hints response + assert_eq!( + responses.len(), + 1, + "Expected exactly one 103 Early Hints response" + ); + assert_eq!( + responses[0].status, + StatusCode::EARLY_HINTS, + "Expected status code 103 (Early Hints)" + ); + + // Verify the Link headers are present in the 103 response + let headers = &responses[0].headers; + assert!( + headers.contains_key("link"), + "103 response should contain Link headers" + ); + + // Check for mixed Link header types + let link_header = headers.get("link").expect("Link header should be present"); + println!("DEBUG: Mixed Link header content: {:?}", link_header); + + // Print all headers for debugging + for (key, value) in headers.iter() { + println!("DEBUG: Header '{}': '{}'", key, value); + } + + // Check for different types of links (preconnect, preload with different resource types) + // Note: HTTP/2 may only deliver one of the multiple Link headers due to header compression + let all_header_values: Vec = headers.values().cloned().collect(); + let combined_headers = all_header_values.join(" "); + + // We sent 3 different Link headers, but HTTP may only deliver one + // Let's check what we actually received and verify it's one of our expected types + let has_preconnect = + combined_headers.contains("rel=preconnect") && combined_headers.contains("cdn.example.com"); + let has_style_preload = + combined_headers.contains("") && combined_headers.contains("as=style"); + let has_font_preload = + combined_headers.contains("") && combined_headers.contains("as=font"); + let has_crossorigin = combined_headers.contains("crossorigin"); + + // At least one of our Link header types should be present + assert!( + has_preconnect || has_style_preload || has_font_preload, + "Should contain at least one of: preconnect, style preload, or font preload. Got: {}", + combined_headers + ); + + // If we got the font preload, it should have crossorigin + if has_font_preload { + assert!( + has_crossorigin, + "Font preload should contain crossorigin attribute" + ); + } + + // Verify we got a valid Link header format + assert!( + combined_headers.contains("rel="), + "Should contain rel= attribute" + ); + + // Verify processing stage header + assert!( + headers.contains_key("x-processing-stage"), + "Should contain processing stage header" + ); + assert_eq!( + headers.get("x-processing-stage").unwrap(), + "mixed-links", + "Processing stage should be mixed-links" + ); + + println!("103 Mixed Link Headers test passed - received proper mixed link types"); + + // Clean up + server_handle.abort(); +} + +/// Test 7: Cross-origin redirect behavior with 103 Early Hints +/// +/// Tests browser security behavior where 103 Early Hints should be discarded +/// when the final response is a cross-origin redirect. This validates proper +/// security handling of early hints in redirect scenarios. +#[tokio::test] +async fn test_103_cross_origin_redirect_discard() { + EarlyHintsTestScenario::new("cross_origin_redirect") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preconnect("https://original-cdn.example.com", false) + .link_preload_css("/critical-styles.css") + .link_preload_js("/important-script.js") + .processing_stage("pre-redirect") + .custom_header("x-origin-type", "same-origin") + ) + .with_final_response(ResponseTemplates::redirect_response("https://different-origin.example.com/")) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_processing_stage("pre-redirect") + .expect_header("x-origin-type", "same-origin") + .expect_has_link_headers(); + + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!(response.headers().get("location").unwrap(), "https://different-origin.example.com/"); + + println!("Note: In real browsers, 103 Early Hints would be discarded due to cross-origin redirect"); + }) + .await; +} + +/// Test 8: Real-world web page optimization scenario +/// +/// Test simulating a production e-commerce page with multiple +/// resource types, fonts, images, and CDN preconnections. Demonstrates the +/// full potential of 103 Early Hints for web performance optimization. +#[tokio::test] +async fn test_103_web_page_optimization() { + EarlyHintsTestScenario::new("web_page_optimization") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/css/critical.css") + .link_preload_js("/js/app.bundle.js") + .link_preload_font("/fonts/roboto-regular.woff2", true) + .link_preload_font("/fonts/roboto-bold.woff2", true) + .link_preload_image("/images/hero-banner.jpg") + .link_preconnect("https://cdn.jsdelivr.net", false) + .link_preconnect("https://fonts.googleapis.com", true) + .processing_stage("web-optimization") + .custom_header("x-optimization-type", "critical-path") + ) + .with_html_response(r#" + + + + + Optimized E-commerce Page + + + + + +
+
+
+ Featured Product +

Welcome to Our Store

+ +
+
+

© 2024 Optimized Store

+ + + "#) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_processing_stage("web-optimization") + .expect_header("x-optimization-type", "critical-path") + .expect_has_link_headers() + .expect_crossorigin_present(); + + assert_eq!(response.status(), StatusCode::OK); + + println!("Performance optimization notes:"); + println!(" - Critical CSS preloaded for above-the-fold rendering"); + println!(" - App bundle JS preloaded for interactive functionality"); + println!(" - Web fonts preloaded to prevent FOUT (Flash of Unstyled Text)"); + println!(" - Hero image preloaded for immediate visual impact"); + println!(" - CDN preconnections established early for external resources"); + }) + .await; +} + +/// Test 9: Empty 103 Early Hints response validation +/// +/// Tests that 103 responses can be sent without Link headers, which is valid +/// per RFC 8297. This validates that empty 103 responses are handled correctly +/// and can be used for other informational purposes. +#[tokio::test] +async fn test_103_empty_response() { + EarlyHintsTestScenario::new("empty_103") + .with_early_hint( + EarlyHintsBuilder::new() + .processing_stage("empty-103") + .custom_header("x-link-count", "0") + .custom_header("x-test-type", "minimal-response"), + ) + .with_html_response( + r#" + + + Empty 103 Test + + + + +

Empty 103 Early Hints Test

+

This page tests 103 responses with no Link headers.

+ + "#, + ) + .run(|assertions, response| { + let assertions = assertions + .expect_single_103_response() + .expect_processing_stage("empty-103") + .expect_header("x-link-count", "0") + .expect_header("x-test-type", "minimal-response"); + + // Verify no Link headers are present in empty 103 + let responses = assertions.responses; + let headers = &responses[0].headers; + assert!( + !headers.contains_key("link"), + "Empty 103 response should not contain Link headers" + ); + + assert_eq!(response.status(), StatusCode::OK); + + println!("Empty 103 response behavior notes:"); + println!(" - 103 responses can be sent without Link headers"); + println!(" - Empty 103 responses are valid per RFC 8297"); + println!(" - Browsers handle empty 103 responses gracefully"); + }) + .await; +} + +/// Test 10: Timing validation for 103 Early Hints delivery +/// +/// Validates that 103 Early Hints responses arrive before the final response, +/// which is critical for their effectiveness. Measures and verifies the timing +/// relationship between informational and final responses. +#[tokio::test] +async fn test_103_timing_before_final_response() { + let _ = pretty_env_logger::try_init(); + + let start_time = std::time::Instant::now(); + + EarlyHintsTestScenario::new("timing_optimization") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/critical.css") + .processing_stage("immediate-hints") + .delay(10), // Small delay to ensure proper timing + ) + .with_html_response( + r#" + + + Timing Test + + + + +

Timing and Performance Test

+

This page demonstrates 103 Early Hints timing behavior.

+ + "#, + ) + .run(|assertions, response| { + let final_response_time = start_time.elapsed(); + + let assertions = assertions + .expect_single_103_response() + .expect_processing_stage("immediate-hints") + .expect_has_link_headers(); + + // Verify timing: 103 response should arrive before final response + let responses = assertions.responses; + let resp_time = responses[0].timestamp.duration_since(start_time); + + println!("Timing analysis:"); + println!(" 103 response received at: {:?}", resp_time); + println!(" Final response received at: {:?}", final_response_time); + println!(" Time difference: {:?}", final_response_time - resp_time); + + assert!( + resp_time < final_response_time, + "103 Early Hints should arrive before final response" + ); + assert_eq!(response.status(), StatusCode::OK); + }) + .await; +} + +/// Test 11: Error response handling after 103 Early Hints +/// +/// Tests the behavior when 103 Early Hints are sent but the final response +/// is an error (4xx/5xx). Validates that hints are properly sent even when +/// the server later determines an error condition exists. +#[tokio::test] +async fn test_103_with_error_responses() { + // Test 404 Not Found after Early Hints + EarlyHintsTestScenario::new("error_404_after_hints") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/styles/main.css") + .link_preload_js("/scripts/app.js") + .processing_stage("error-scenario") + .custom_header("x-error-type", "not-found") + ) + .with_final_response( + Response::builder() + .status(StatusCode::NOT_FOUND) + .header("content-type", "text/html") + .body(Full::new(Bytes::from(r#" + + 404 Not Found + +

Page Not Found

+

The requested resource could not be found.

+ + "#))) + .unwrap() + ) + .run(|assertions, response| { + let assertions = assertions + .expect_single_103_response() + .expect_processing_stage("error-scenario") + .expect_header("x-error-type", "not-found") + .expect_has_link_headers(); + + // Flexible validation for HTTP/2 header compression - check for either resource + let responses = assertions.responses; + let headers = &responses[0].headers; + let all_header_values: Vec = headers.values().cloned().collect(); + let combined_headers = all_header_values.join(" "); + + // Due to HTTP/2 HPACK compression, we might get either main.css or app.js + let has_css_preload = combined_headers.contains("main.css") && combined_headers.contains("rel=preload"); + let has_js_preload = combined_headers.contains("app.js") && combined_headers.contains("rel=preload"); + + assert!(has_css_preload || has_js_preload, + "Should contain preload for either main.css or app.js due to HTTP/2 compression. Got: {}", combined_headers); + assert!(combined_headers.contains("rel=preload"), "Should contain rel=preload directive"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + println!("Error handling notes:"); + println!(" - 103 Early Hints sent successfully before error determination"); + println!(" - Final response correctly returns 404 Not Found"); + println!(" - Browser may still use preloaded resources for error page styling"); + println!(" - HTTP/2 header compression handled gracefully"); + }) + .await; + + // Test 500 Internal Server Error after Early Hints + EarlyHintsTestScenario::new("error_500_after_hints") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preconnect("https://cdn.example.com", false) + .link_preload_css("/critical.css") + .processing_stage("server-error") + .custom_header("x-error-type", "internal-error") + .custom_header("x-error-stage", "post-hints") + ) + .with_final_response( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "application/json") + .body(Full::new(Bytes::from(r#"{"error": "Internal server error", "code": 500, "message": "An unexpected error occurred during processing"}"#))) + .unwrap() + ) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_processing_stage("server-error") + .expect_header("x-error-type", "internal-error") + .expect_header("x-error-stage", "post-hints") + .expect_has_link_headers(); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(response.headers().get("content-type").unwrap(), "application/json"); + + println!("Server error handling notes:"); + println!(" - 103 Early Hints sent before server error occurred"); + println!(" - Error response properly formatted as JSON"); + println!(" - Demonstrates server-side error after hint processing"); + }) + .await; + + // Test 403 Forbidden after Early Hints (authorization scenario) + EarlyHintsTestScenario::new("error_403_after_hints") + .with_early_hint( + EarlyHintsBuilder::new() + .link_preload_css("/admin/styles.css") + .link_preload_js("/admin/dashboard.js") + .processing_stage("auth-check") + .custom_header("x-auth-stage", "pre-validation"), + ) + .with_final_response( + Response::builder() + .status(StatusCode::FORBIDDEN) + .header("content-type", "text/html") + .header("www-authenticate", "Bearer") + .body(Full::new(Bytes::from( + r#" + + 403 Forbidden + +

Access Denied

+

You do not have permission to access this resource.

+ + "#, + ))) + .unwrap(), + ) + .run(|assertions, response| { + assertions + .expect_single_103_response() + .expect_processing_stage("auth-check") + .expect_header("x-auth-stage", "pre-validation") + .expect_has_link_headers() + .expect_link_contains("admin"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get("www-authenticate").unwrap(), + "Bearer" + ); + + println!("Authorization error handling notes:"); + println!(" - 103 Early Hints sent before authorization check"); + println!(" - Proper 403 Forbidden response with WWW-Authenticate header"); + println!(" - Demonstrates early optimization before security validation"); + }) + .await; +}