Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

support query param for delay #47

Merged
merged 5 commits into from
Dec 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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