diff --git a/crates/bsnext_core/src/dynamic_query_params.rs b/crates/bsnext_core/src/dynamic_query_params.rs new file mode 100644 index 0000000..e39f009 --- /dev/null +++ b/crates/bsnext_core/src/dynamic_query_params.rs @@ -0,0 +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")] + 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 { + 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 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 73048fc..279beca 100644 --- a/crates/bsnext_core/src/lib.rs +++ b/crates/bsnext_core/src/lib.rs @@ -2,6 +2,7 @@ pub mod server; pub mod servers_supervisor; pub mod dir_loader; +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 6c098f5..fb7daa9 100644 --- a/crates/bsnext_core/src/optional_layers.rs +++ b/crates/bsnext_core/src/optional_layers.rs @@ -1,3 +1,4 @@ +use crate::dynamic_query_params; use axum::extract::{Request, State}; use axum::middleware::{map_response_with_state, Next}; use axum::response::{IntoResponse, Response}; @@ -5,9 +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 http::header::{CACHE_CONTROL, EXPIRES, PRAGMA}; +use dynamic_query_params::dynamic_query_params_handler; use http::{HeaderName, HeaderValue}; use std::collections::BTreeMap; use std::convert::Infallible; @@ -42,21 +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 3936e12..cc80906 100644 --- a/crates/bsnext_core/tests/delays.rs +++ b/crates/bsnext_core/tests/delays.rs @@ -32,6 +32,27 @@ async fn test_delays() -> Result<(), anyhow::Error> { Ok(()) } +#[tokio::test] +async fn test_delays_01() -> Result<(), anyhow::Error> { + let input = r#" +servers: + - name: first-byte-delays + routes: + - path: / + raw: hello world! + "#; + let state = from_yaml(&input)?; + + let (parts1, body1, dur1) = uri_to_res_parts(state.clone(), "/?bslive.delay.ms=200").await; + let dur1_ms = dur1.as_millis(); + + assert_eq!(body1, "hello world!"); + assert_eq!(parts1.status, 200); + assert!(dur1_ms > 200 && dur1_ms < 210); + + Ok(()) +} + #[tokio::test] async fn test_proxy_delay() -> Result<(), anyhow::Error> { let proxy_app = Router::new().route("/", get(|| async { "target - proxy delay" })); @@ -43,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_html/src/lib.rs b/crates/bsnext_html/src/lib.rs index 2bbff9b..8a96559 100644 --- a/crates/bsnext_html/src/lib.rs +++ b/crates/bsnext_html/src/lib.rs @@ -72,11 +72,12 @@ fn playground_html_str_to_input(html: &str, ctx: &InputCtx) -> Result Some(format!("{} {}", name, content)), - (Some(name), None) => Some(format!("{}", name)), + (Some(name), None) => Some(name.to_string()), _ => None, }; if let Some(joined) = joined { + tracing::trace!("Joined meta : {}", joined); if let Ok(route) = Route::from_cli_str(joined) { routes.push(route); node_ids_to_remove.push(meta_elem.id()); diff --git a/crates/bsnext_input/src/route_cli.rs b/crates/bsnext_input/src/route_cli.rs index 462c3f1..9fe00e8 100644 --- a/crates/bsnext_input/src/route_cli.rs +++ b/crates/bsnext_input/src/route_cli.rs @@ -1,5 +1,5 @@ use crate::path_def::PathDef; -use crate::route::{DirRoute, Route, RouteKind}; +use crate::route::{DelayKind, DelayOpts, DirRoute, Opts, Route, RouteKind}; use clap::Parser; use shell_words::split; @@ -22,11 +22,28 @@ impl TryInto for RouteCli { fn try_into(self) -> Result { Ok(match self.command { - SubCommands::ServeDir { dir, path } => Route { + SubCommands::ServeDir { + dir, + path, + delay: Some(0), + } => Route { path: PathDef::try_new(path)?, kind: RouteKind::Dir(DirRoute { dir, base: None }), ..std::default::Default::default() }, + SubCommands::ServeDir { + dir, + path, + delay: ms, + } => Route { + path: PathDef::try_new(path)?, + kind: RouteKind::Dir(DirRoute { dir, base: None }), + opts: Opts { + delay: ms.map(|ms| DelayOpts::Delay(DelayKind::Ms(ms))), + ..std::default::Default::default() + }, + ..std::default::Default::default() + }, }) } } @@ -36,11 +53,14 @@ pub enum SubCommands { /// does testing things ServeDir { /// Which path should this directory be served from - #[arg(short, long, default_value = "/")] + #[arg(long, default_value = "/")] path: String, /// Which directory should be served - #[arg(short, long, default_value = ".")] + #[arg(long, default_value = ".")] dir: String, + + #[arg(long)] + delay: Option, }, } @@ -55,9 +75,17 @@ mod test { let input = "bslive serve-dir --path=/ --dir=examples/basic/public"; let as_args = split(input)?; let parsed = RouteCli::try_parse_from(as_args)?; - let as_route: Result = parsed.try_into(); - dbg!(&as_route); - // assert_debug_snapshot!(parsed); + let as_route: Route = parsed.try_into().unwrap(); + insta::assert_debug_snapshot!(as_route); + Ok(()) + } + #[test] + fn test_serve_dir_delay() -> anyhow::Result<()> { + let input = "bslive serve-dir --path=/ --dir=examples/basic/public --delay=1000"; + let as_args = split(input)?; + let parsed = RouteCli::try_parse_from(as_args)?; + let as_route: Route = parsed.try_into().unwrap(); + insta::assert_debug_snapshot!(as_route); Ok(()) } } diff --git a/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir.snap b/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir.snap index 6e262f6..83ba872 100644 --- a/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir.snap +++ b/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir.snap @@ -1,10 +1,29 @@ --- source: crates/bsnext_input/src/route_cli.rs -expression: parsed +expression: as_route --- -RouteCli { - command: ServeDir { - path: "/", - dir: "examples/basic/public", +Route { + path: PathDef { + inner: "/", }, + kind: Dir( + DirRoute { + dir: "examples/basic/public", + base: None, + }, + ), + opts: Opts { + cors: None, + delay: None, + watch: Bool( + true, + ), + inject: Bool( + true, + ), + headers: None, + cache: Prevent, + compression: None, + }, + fallback: None, } diff --git a/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir_delay.snap b/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir_delay.snap new file mode 100644 index 0000000..a0cf771 --- /dev/null +++ b/crates/bsnext_input/src/snapshots/bsnext_input__route_cli__test__serve_dir_delay.snap @@ -0,0 +1,35 @@ +--- +source: crates/bsnext_input/src/route_cli.rs +expression: as_route +--- +Route { + path: PathDef { + inner: "/", + }, + kind: Dir( + DirRoute { + dir: "examples/basic/public", + base: None, + }, + ), + opts: Opts { + cors: None, + delay: Some( + Delay( + Ms( + 1000, + ), + ), + ), + watch: Bool( + true, + ), + inject: Bool( + true, + ), + headers: None, + cache: Prevent, + compression: None, + }, + fallback: None, +} 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![] + } + } + } +} diff --git a/examples/basic/delays.yml b/examples/basic/delays.yml index e8197f8..e44b037 100644 --- a/examples/basic/delays.yml +++ b/examples/basic/delays.yml @@ -8,6 +8,8 @@ servers: html: first - 200ms delay delay: ms: 200 + - path: /none + html: no config-based delay - path: /500 html: second - 500ms delay delay: diff --git a/tests/delays.spec.ts b/tests/delays.spec.ts index 47dd682..217bb00 100644 --- a/tests/delays.spec.ts +++ b/tests/delays.spec.ts @@ -23,6 +23,19 @@ test.describe( expect(diff).toBeGreaterThan(200); expect(diff).toBeLessThan(300); }); + test("no config-based delay (url param)", async ({ request, bs }) => { + const start = Date.now(); + const response = await request.get( + bs.path("/none?bslive.delay.ms=200"), + ); + + const body = await response.body(); + const diff = Date.now() - start; + + expect(body.toString()).toBe(`no config-based delay`); + expect(diff).toBeGreaterThan(200); + expect(diff).toBeLessThan(300); + }); test("500ms delay", async ({ request, bs }) => { const start = Date.now(); const response = await request.get(bs.path("/500"));