Skip to content

Commit

Permalink
Merge pull request #47 from BrowserSync/query-params
Browse files Browse the repository at this point in the history
support query param for delay
  • Loading branch information
shakyShane authored Dec 25, 2024
2 parents 0b7357f + 7a337d6 commit c466317
Show file tree
Hide file tree
Showing 14 changed files with 456 additions and 28 deletions.
63 changes: 63 additions & 0 deletions crates/bsnext_core/src/dynamic_query_params.rs
Original file line number Diff line number Diff line change
@@ -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<u64>,
/// Control if Browsersync will add cache-busting headers, or not.
#[serde(rename = "bslive.cache")]
pub cache: Option<CacheOpts>,
}

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)
}
1 change: 1 addition & 0 deletions crates/bsnext_core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 6 additions & 14 deletions crates/bsnext_core/src/optional_layers.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
use crate::dynamic_query_params;
use axum::extract::{Request, State};
use axum::middleware::{map_response_with_state, Next};
use axum::response::{IntoResponse, Response};
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;
Expand Down Expand Up @@ -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));
Expand Down
140 changes: 140 additions & 0 deletions crates/bsnext_core/src/query-params.md
Original file line number Diff line number Diff line change
@@ -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::<Vec<(String, String)>>();

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::<Vec<(String, String)>>();

assert_eq!(pairs, expected);
Ok(())
}
```
13 changes: 13 additions & 0 deletions crates/bsnext_core/src/server/router/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ pub fn from_yaml(yaml: &str) -> anyhow::Result<ServerState> {
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::<Vec<_>>()
}

pub struct TestProxy {
pub socker_addr: SocketAddr,
Expand Down
77 changes: 77 additions & 0 deletions crates/bsnext_core/tests/cache.rs
Original file line number Diff line number Diff line change
@@ -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::<Vec<(String, String)>>();

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::<Vec<(String, String)>>();

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::<Vec<(String, String)>>();

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::<Vec<(String, String)>>();

assert_eq!(pairs, expected);

Ok(())
}
23 changes: 22 additions & 1 deletion crates/bsnext_core/tests/delays.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand All @@ -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"
Expand Down
Loading

0 comments on commit c466317

Please sign in to comment.