From 7a337d6b90235bb147b9c68e6dec5579df8f173d Mon Sep 17 00:00:00 2001 From: Shane Osbourne Date: Wed, 25 Dec 2024 21:35:12 +0000 Subject: [PATCH] docs --- .../bsnext_core/src/dynamic_query_params.rs | 50 ++++++- crates/bsnext_core/src/lib.rs | 2 +- crates/bsnext_core/src/optional_layers.rs | 17 +-- crates/bsnext_core/src/query-params.md | 140 ++++++++++++++++++ .../bsnext_core/src/server/router/common.rs | 13 ++ crates/bsnext_core/tests/cache.rs | 77 ++++++++++ crates/bsnext_core/tests/delays.rs | 2 +- crates/bsnext_resp/src/cache_opts.rs | 23 +++ 8 files changed, 304 insertions(+), 20 deletions(-) create mode 100644 crates/bsnext_core/src/query-params.md create mode 100644 crates/bsnext_core/tests/cache.rs diff --git a/crates/bsnext_core/src/dynamic_query_params.rs b/crates/bsnext_core/src/dynamic_query_params.rs index e302783..e39f009 100644 --- a/crates/bsnext_core/src/dynamic_query_params.rs +++ b/crates/bsnext_core/src/dynamic_query_params.rs @@ -1,21 +1,63 @@ use axum::extract::{Query, Request}; use axum::middleware::Next; use axum::response::IntoResponse; +use bsnext_resp::cache_opts::CacheOpts; use std::convert::Infallible; use std::time::Duration; use tokio::time::sleep; +#[doc = include_str!("./query-params.md")] #[derive(Debug, serde::Deserialize)] pub struct DynamicQueryParams { /// Allow a request to have a ?bslive.delay.ms=200 style param to simulate a TTFB delay #[serde(rename = "bslive.delay.ms")] - delay: Option, + pub delay: Option, + /// Control if Browsersync will add cache-busting headers, or not. + #[serde(rename = "bslive.cache")] + pub cache: Option, } pub async fn dynamic_query_params_handler(req: Request, next: Next) -> impl IntoResponse { - if let Ok(Query(DynamicQueryParams { delay: Some(ms) })) = Query::try_from_uri(req.uri()) { - sleep(Duration::from_millis(ms)).await; + let Ok(Query(query_params)) = Query::try_from_uri(req.uri()) else { + let res = next.run(req).await; + return Ok::<_, Infallible>(res); + }; + + // things to apply *before* + #[allow(clippy::single_match)] + match &query_params { + DynamicQueryParams { + delay: Some(ms), .. + } => { + sleep(Duration::from_millis(*ms)).await; + } + _ => {} } - let res = next.run(req).await; + + let mut res = next.run(req).await; + + // things to apply *after* + #[allow(clippy::single_match)] + match query_params { + DynamicQueryParams { + cache: Some(cache_opts), + .. + } => match cache_opts { + CacheOpts::Prevent => { + let headers_to_add = cache_opts.as_headers(); + for (name, value) in headers_to_add { + res.headers_mut().insert(name, value); + } + } + CacheOpts::Default => { + let headers = CacheOpts::Prevent.as_headers(); + for (name, _) in headers { + res.headers_mut().remove(name); + } + } + }, + _ => {} + } + Ok::<_, Infallible>(res) } diff --git a/crates/bsnext_core/src/lib.rs b/crates/bsnext_core/src/lib.rs index 71f2ee0..279beca 100644 --- a/crates/bsnext_core/src/lib.rs +++ b/crates/bsnext_core/src/lib.rs @@ -2,7 +2,7 @@ pub mod server; pub mod servers_supervisor; pub mod dir_loader; -mod dynamic_query_params; +pub mod dynamic_query_params; pub mod export; pub mod handler_stack; pub mod handlers; diff --git a/crates/bsnext_core/src/optional_layers.rs b/crates/bsnext_core/src/optional_layers.rs index 5b6e8a4..fb7daa9 100644 --- a/crates/bsnext_core/src/optional_layers.rs +++ b/crates/bsnext_core/src/optional_layers.rs @@ -6,10 +6,8 @@ use axum::routing::MethodRouter; use axum::{middleware, Extension}; use axum_extra::middleware::option_layer; use bsnext_input::route::{CompType, CompressionOpts, CorsOpts, DelayKind, DelayOpts, Opts}; -use bsnext_resp::cache_opts::CacheOpts; use bsnext_resp::{response_modifications_layer, InjectHandling}; use dynamic_query_params::dynamic_query_params_handler; -use http::header::{CACHE_CONTROL, EXPIRES, PRAGMA}; use http::{HeaderName, HeaderValue}; use std::collections::BTreeMap; use std::convert::Infallible; @@ -44,22 +42,13 @@ pub fn optional_layers(app: MethodRouter, opts: &Opts) -> MethodRouter { .as_ref() .map(|headers| map_response_with_state(headers.clone(), set_resp_headers_from_strs)); - let prevent_cache_headers_layer = matches!(opts.cache, CacheOpts::Prevent).then(|| { - let headers: Vec<(HeaderName, HeaderValue)> = vec![ - ( - CACHE_CONTROL, - HeaderValue::from_static("no-store, no-cache, must-revalidate"), - ), - (PRAGMA, HeaderValue::from_static("no-cache")), - (EXPIRES, HeaderValue::from_static("0")), - ]; - map_response_with_state(headers, set_resp_headers) - }); + let headers = opts.cache.as_headers(); + let prevent_cache_headers_layer = map_response_with_state(headers, set_resp_headers); let optional_stack = ServiceBuilder::new() .layer(middleware::from_fn(dynamic_query_params_handler)) .layer(option_layer(inject_layer)) - .layer(option_layer(prevent_cache_headers_layer)) + .layer(prevent_cache_headers_layer) .layer(option_layer(set_response_headers_layer)) .layer(option_layer(cors_enabled_layer)) .layer(option_layer(delay_enabled_layer)); diff --git a/crates/bsnext_core/src/query-params.md b/crates/bsnext_core/src/query-params.md new file mode 100644 index 0000000..c6bd90c --- /dev/null +++ b/crates/bsnext_core/src/query-params.md @@ -0,0 +1,140 @@ +# [DynamicQueryParams] + +Dynamically adjust how requests and responses will be handled on the fly, using +query params. + +**Features** + +- [delay](#delay-example) - simulate a delay in TTFB. +- [cache](#cache-example) - add or remove the headers that Browsersync to control cache + +--- + +## Delay + +You can control a simulated delay by appending the query param as seen below. + +- Note: Only milliseconds are supported right now. +- Note: If there's a typo, of if the value cannot be converted into a millisecond representation + no error will be thrown, it will simply be ignored. + +**When is this useful?** + +You can use it to optionally cause an asset to be delayed in its response + +### Delay example + +```rust +# use bsnext_core::server::router::common::from_yaml_blocking; +fn main() -> anyhow::Result<()> { + let req = "/abc?bslive.delay.ms=200"; + let server_yaml = r#" + servers: + - name: test + routes: + - path: /abc + html: hello world! + "#; + + let (parts, body, duration) = from_yaml_blocking(server_yaml, req)?; + let duration_millis = duration.as_millis(); + + assert_eq!(body, "hello world!"); + assert_eq!(parts.status, 200); + assert!(duration_millis > 200 && duration_millis < 210); + Ok(()) +} +``` + +### Delay CLI Example + +```bash +bslive examples/basic/public -p 3000 + +# then, in another terminal +curl localhost:3000?bslive.delay.ms=2000 +``` + +### Cache example + +The normal behaviour in Browsersync is to add the following HTTP headers to requests in development. + +- `cache-control: no-store, no-cache, must-revalidate` +- `pragma: no-cache` +- `expires: 0` + +Those indicate that the browser should re-fetch the assets frequently. If you want to override this behavior, you can +provide the query param seen below: + +- `?bslive.cache=default` <- this prevents Browsersync from adding any cache headers (defaulting to whatever the browser + decides) +- `?bslive.cache=prevent` <- this will cause the headers above to be added. + +```rust +# use bsnext_core::server::router::common::{from_yaml_blocking, header_pairs}; +fn main() -> anyhow::Result<()> { + let server_yaml = r#" + servers: + - name: test + routes: + - path: /abc + html: hello world! + "#; + + let (parts1, _, _) = from_yaml_blocking(server_yaml, "/abc?bslive.cache=default")?; + let pairs = header_pairs(&parts1); + + // Note: now the extra 3 headers are present + let expected = vec![ + ("content-type", "text/html; charset=utf-8"), + ("content-length", "12") + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(); + + assert_eq!(pairs, expected); + Ok(()) +} +``` + +## Cache example, overriding config + +At the route-level, you can remove the headers that Browsersync adds to bust caches, by simply putting +`cache: default` at any route-level config. + +Then, on a case-by-case basis you can re-enable it. + +```rust +# use bsnext_core::server::router::common::{from_yaml_blocking, header_pairs}; +fn main() -> anyhow::Result<()> { + // Note the `cache: default` here. This stops Browsersync adding any headers for cache-busting + let server_yaml = r#" + servers: + - name: test + cache: default + routes: + - path: /abc + html: hello world! + "#; + + // But, now we can re-enable cache-busting on a single URL + let (parts1, _, _) = from_yaml_blocking(server_yaml, "/abc?bslive.cache=prevent")?; + let pairs = header_pairs(&parts1); + + // Note: only the 2 headers are present now, otherwise there would be 5 + let expected = vec![ + ("content-type", "text/html; charset=utf-8"), + ("content-length", "12"), + ("cache-control", "no-store, no-cache, must-revalidate"), + ("pragma", "no-cache"), + ("expires", "0"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(); + + assert_eq!(pairs, expected); + Ok(()) +} +``` \ No newline at end of file diff --git a/crates/bsnext_core/src/server/router/common.rs b/crates/bsnext_core/src/server/router/common.rs index 71dc803..4cc2f63 100644 --- a/crates/bsnext_core/src/server/router/common.rs +++ b/crates/bsnext_core/src/server/router/common.rs @@ -94,6 +94,19 @@ pub fn from_yaml(yaml: &str) -> anyhow::Result { let state = into_state(config); Ok(state) } +pub fn from_yaml_blocking(yaml: &str, uri: &str) -> anyhow::Result<(Parts, String, Duration)> { + let state = from_yaml(yaml)?; + tokio::runtime::Runtime::new()? + .block_on(async { Ok(uri_to_res_parts(state.clone(), uri).await) }) +} + +pub fn header_pairs(parts: &Parts) -> Vec<(String, String)> { + parts + .headers + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap().to_string())) + .collect::>() +} pub struct TestProxy { pub socker_addr: SocketAddr, diff --git a/crates/bsnext_core/tests/cache.rs b/crates/bsnext_core/tests/cache.rs new file mode 100644 index 0000000..8b251d0 --- /dev/null +++ b/crates/bsnext_core/tests/cache.rs @@ -0,0 +1,77 @@ +use bsnext_core::server::router::common::{from_yaml_blocking, header_pairs}; + +#[test] +fn test_cache_query_param() -> Result<(), anyhow::Error> { + let input = r#" +servers: + - name: cache_defaults + routes: + - path: / + raw: hello world! + "#; + + let (parts1, _, _) = from_yaml_blocking(input, "/")?; + let pairs = header_pairs(&parts1); + + let control = vec![ + ("content-type", "text/plain"), + ("content-length", "12"), + ("cache-control", "no-store, no-cache, must-revalidate"), + ("pragma", "no-cache"), + ("expires", "0"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(); + + assert_eq!(pairs, control); + + let (parts1, _, _) = from_yaml_blocking(input, "/?bslive.cache=default")?; + let pairs = header_pairs(&parts1); + let expected = vec![("content-type", "text/plain"), ("content-length", "12")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(); + + assert_eq!(pairs, expected); + + Ok(()) +} +#[test] +fn test_cache_query_param_overrides_main() -> Result<(), anyhow::Error> { + let input = r#" +servers: + - name: cache_defaults + routes: + - path: /abc + raw: hello world! + cache: default + "#; + + let (parts1, _, _) = from_yaml_blocking(input, "/abc")?; + let pairs = header_pairs(&parts1); + + let control = vec![("content-type", "text/plain"), ("content-length", "12")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(); + + assert_eq!(pairs, control); + + let (parts1, _, _) = from_yaml_blocking(input, "/abc?bslive.cache=prevent")?; + let pairs = header_pairs(&parts1); + let expected = vec![ + ("content-type", "text/plain"), + ("content-length", "12"), + ("cache-control", "no-store, no-cache, must-revalidate"), + ("pragma", "no-cache"), + ("expires", "0"), + ] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(); + + assert_eq!(pairs, expected); + + Ok(()) +} diff --git a/crates/bsnext_core/tests/delays.rs b/crates/bsnext_core/tests/delays.rs index 8f85b3d..cc80906 100644 --- a/crates/bsnext_core/tests/delays.rs +++ b/crates/bsnext_core/tests/delays.rs @@ -64,7 +64,7 @@ async fn test_proxy_delay() -> Result<(), anyhow::Error> { // update the proxy target to use local test proxy let mut config: ServerConfig = input.servers.first().expect("first").to_owned(); - let proxy_route = config.routes.get_mut(3).unwrap(); + let proxy_route = config.routes.get_mut(4).unwrap(); assert!( matches!(proxy_route.kind, RouteKind::Proxy(..)), "must be a proxy route, check delays.yml" diff --git a/crates/bsnext_resp/src/cache_opts.rs b/crates/bsnext_resp/src/cache_opts.rs index 1d247f6..c72f04e 100644 --- a/crates/bsnext_resp/src/cache_opts.rs +++ b/crates/bsnext_resp/src/cache_opts.rs @@ -1,3 +1,6 @@ +use http::header::{CACHE_CONTROL, EXPIRES, PRAGMA}; +use http::{HeaderName, HeaderValue}; + #[derive(Debug, Default, PartialEq, Hash, Clone, serde::Deserialize, serde::Serialize)] pub enum CacheOpts { /// Try to prevent browsers from caching the responses. This is the default behaviour @@ -8,3 +11,23 @@ pub enum CacheOpts { #[serde(rename = "default")] Default, } + +impl CacheOpts { + pub fn as_headers(&self) -> Vec<(HeaderName, HeaderValue)> { + match self { + CacheOpts::Prevent => { + vec![ + ( + CACHE_CONTROL, + HeaderValue::from_static("no-store, no-cache, must-revalidate"), + ), + (PRAGMA, HeaderValue::from_static("no-cache")), + (EXPIRES, HeaderValue::from_static("0")), + ] + } + CacheOpts::Default => { + vec![] + } + } + } +}