Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/base-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
livekit-api: patch
---

fix: enable using base URLs with path extensions
35 changes: 35 additions & 0 deletions livekit-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

#![doc = include_str!("../README.md")]

use url::Url;

#[cfg(feature = "access-token")]
pub mod access_token;

Expand Down Expand Up @@ -60,3 +62,36 @@ pub(crate) fn get_env_keys() -> Result<(String, String), std::env::VarError> {
let api_secret = std::env::var("LIVEKIT_API_SECRET")?;
Ok((api_key, api_secret))
}

/// Creates a new URL by appending `suffix` to the supplied `base` URL without overwriting its path.
fn url_with_path_suffix(base: &Url, suffix: &str) -> Url {
let mut path = base.path().trim_end_matches('/').to_owned();
path.push_str(suffix);
let mut url = base.clone();
url.set_path(&path);
url
}

#[cfg(test)]
mod tests {
use crate::url_with_path_suffix;
use url::Url;

#[test]
fn url_with_path_suffix_no_path_on_base() {
let url = url_with_path_suffix(&Url::parse("https://foo.bar").unwrap(), "/foobar");
assert_eq!(url.to_string(), "https://foo.bar/foobar");
}

#[test]
fn url_with_path_suffix_root_path_on_base() {
let url = url_with_path_suffix(&Url::parse("https://foo.bar/").unwrap(), "/foobar");
assert_eq!(url.to_string(), "https://foo.bar/foobar");
}

#[test]
fn url_with_path_suffix_some_path_on_base() {
let url = url_with_path_suffix(&Url::parse("https://foo.bar/bar").unwrap(), "/foobar");
assert_eq!(url.to_string(), "https://foo.bar/bar/foobar");
}
}
9 changes: 5 additions & 4 deletions livekit-api/src/services/failover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ fn max_age_from_cache_control(value: Option<&str>) -> Option<Duration> {

#[cfg(feature = "services-tokio")]
async fn fetch(base: &Url, headers: &HeaderMap) -> Result<(Vec<String>, Option<Duration>), ()> {
let mut url = base.clone();
url.set_path("/settings/regions");
use crate::url_with_path_suffix;

let url = url_with_path_suffix(base, "/settings/regions");
Comment thread
Johennes marked this conversation as resolved.

let resp = reqwest::Client::new()
.get(url)
Expand All @@ -200,11 +201,11 @@ async fn fetch(base: &Url, headers: &HeaderMap) -> Result<(Vec<String>, Option<D

#[cfg(all(feature = "services-async", not(feature = "services-tokio")))]
async fn fetch(base: &Url, headers: &HeaderMap) -> Result<(Vec<String>, Option<Duration>), ()> {
use crate::url_with_path_suffix;
use isahc::config::Configurable;
use isahc::AsyncReadResponseExt;

let mut url = base.clone();
url.set_path("/settings/regions");
let url = url_with_path_suffix(base, "/settings/regions");

let mut builder = isahc::Request::get(url.as_str()).timeout(DISCOVERY_TIMEOUT);
// isahc vendors `http` 0.2, so pass name/value as &str/&[u8] to stay agnostic
Expand Down
1 change: 1 addition & 0 deletions livekit-api/src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

use http::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use thiserror::Error;
use url::Url;

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / livekit-api (services (async / isahc), cargo test -p livekit-api --no-default-features --features...

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / livekit-api (services (tokio), cargo test -p livekit-api --no-default-features --features service...

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Dart package tests

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (aarch64-unknown-linux-gnu)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Test (x86_64-unknown-linux-gnu)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (x86_64-apple-darwin)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (x86_64-unknown-linux-gnu)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (x86_64-pc-windows-msvc)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Test (aarch64-apple-darwin)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (aarch64-apple-ios-sim)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (aarch64-apple-darwin)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (aarch64-pc-windows-msvc)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Test (x86_64-pc-windows-msvc)

unused import: `url::Url`

Check warning on line 20 in livekit-api/src/services/mod.rs

View workflow job for this annotation

GitHub Actions / Build (aarch64-apple-ios)

unused import: `url::Url`

use crate::access_token::{AccessToken, AccessTokenError, SIPGrants, VideoGrants};

Expand Down
9 changes: 5 additions & 4 deletions livekit-api/src/services/twirp_client.rs
Comment thread
Johennes marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use thiserror::Error;
use url::Url;

use super::failover::{self, FailoverConfig};
use crate::http_client;
use crate::{http_client, url_with_path_suffix};

pub const DEFAULT_PREFIX: &str = "/twirp";

Expand Down Expand Up @@ -215,7 +215,6 @@ impl TwirpClient {
timeout: Duration,
) -> ServerResult<R> {
let original = Url::parse(&self.host)?;
let path = format!("{}/{}.{}/{}", self.prefix, self.pkg, service, method);
headers.insert(USER_AGENT, HeaderValue::from_static(USER_AGENT_VALUE));
#[cfg(test)]
for (k, v) in &self.default_headers {
Expand All @@ -232,8 +231,10 @@ impl TwirpClient {

for attempt in 0..max_attempts {
let is_last = attempt + 1 >= max_attempts;
let mut url = current.clone();
url.set_path(&path);
let url = url_with_path_suffix(
&current,
&format!("{}/{}.{}/{}", self.prefix, self.pkg, service, method),
);
Comment on lines +234 to +237

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Failover region URLs will not inherit the original base path

When a request fails over to a region URL, current is replaced by the URL returned from failover::pick_next (livekit-api/src/services/twirp_client.rs:289-290), which comes from the /settings/regions discovery response. These region URLs are bare host URLs (e.g. https://region1.livekit.cloud) without any path prefix. So url_with_path_suffix(&current, ...) at livekit-api/src/services/twirp_client.rs:234-237 will only append the Twirp service path, without the original base URL's path extension. This is likely correct — region URLs point directly at LiveKit Cloud servers, not through the same reverse proxy — but it means the base-path feature only applies to the primary endpoint, not failover targets. Worth confirming this matches the intended behavior for users behind a path-based reverse proxy.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@Johennes Johennes Jul 7, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is fine because next comes from the server so it should hopefully already have any required path?


let send = self
.client
Expand Down
4 changes: 2 additions & 2 deletions livekit-api/src/signal_client/region_url_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ use http::header::{HeaderMap, HeaderValue, AUTHORIZATION, CACHE_CONTROL};
use parking_lot::Mutex;
use tokio::sync::Mutex as AsyncMutex;

use crate::http_client;
use crate::region::{is_cloud_host, parse_max_age, Cached, RegionCache, RegionsResponse};
use crate::{http_client, url_with_path_suffix};

use super::{SignalError, SignalResult, REGION_FETCH_TIMEOUT};

Expand Down Expand Up @@ -207,7 +207,7 @@ fn region_endpoint(url: &str) -> SignalResult<String> {
"ws" => url.set_scheme("http").unwrap(),
_ => (),
}
url.set_path("/settings/regions");
let url = url_with_path_suffix(&url, "/settings/regions");

Ok(url.to_string())
}
Expand Down
Loading