Add a TokenSource crate to the Rust SDKs - #1274
Conversation
ladvoc
left a comment
There was a problem hiding this comment.
Left some initial comments, will return for a more thorough review when this is taken out of draft.
1egoman
left a comment
There was a problem hiding this comment.
I saw you opened this so I left some initial thoughts, feel free to ignore them for now if it's still early and not yet ready for a review.
|
Potential follow up: Mint tokens based on API secret |
|
@ladvoc this thing is already minting tokens from api key and api secret right? Are we using this in the rust-dev-client? |
f690b60 to
e63f0ef
Compare
# This is the 1st commit message: Started with token source # This is the commit message #2: Before http
Before http Moved everything into a modular crate Without borrow seems to work Token source with tokio async Move reqwest into workspace Token fetching from sandbox works TokenSourceEndpoint works the same as sandbox Using composition for sandbox Error status codes are working Remove room mod change Version 1 done, works like Unity First round of review changes done Switching to livekit-net crate instead of reqwest Revert putting reqwest dependency in workspace Remove test errors Renaming to development token server
dea4dce to
1d0d082
Compare
Changeset ✓This PR includes a changeset covering all affected packages:
|
81e7a4d to
30aaa7e
Compare
The versioned_files dependency entry named livekit-net, so releasing livekit-token-source would have rewritten the livekit-net version pin in the workspace Cargo.toml instead of its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…traits Mirrors the JS SDK's TokenSourceFixed / TokenSourceConfigurable split: fixed sources fetch without options, configurable sources take TokenSourceFetchOptions. Endpoint and DevelopmentTokenServer implement Configurable; Literal implements Fixed and now stores the response directly, removing the never-constructed Err arm. This gives generic call sites (a future Room::connect) and custom credential backends a common interface. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matches the JS SDK's TokenSourceFetchOptions field name; the wire mapping (room_config.agents[0].deployment) is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tolerate camelCase responses The documented endpoint contract is snake_case; serde aliases add the same leniency the JS SDK gets from proto3 fromJson, which also accepts camelCase field names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ports the JS SDK doc comments, including the production-use warning on the development token server, and adds crate-level docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Repo convention (livekit, livekit-api) is to ship no TLS in defaults and let consumers opt in; the example now enables rustls-tls-native-roots explicitly and uses the workspace dependency like the other examples. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removes the committed live sandbox id in favor of LIVEKIT_SANDBOX_ID and tidies the output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cargo.toml declares readme = "README.md" but the file was empty. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1egoman
left a comment
There was a problem hiding this comment.
Really nice work! I've left a bunch of small / nitpicky comments which I noticed when going through, but I think this generally makes sense to me.
One higher level / larger question: I see you decided to punt the TokenSourceMinter question to the future. Totally fine with me, but I am just raising that because I think @ladvoc might have a use case for it in livekit-capture. It might be worth chatting with him about it.
| let literal = TokenSource::literal(TokenSourceResponse { | ||
| server_url: "wss://example.livekit.cloud".to_string(), | ||
| participant_token: "<a pre-generated token>".to_string(), | ||
| }); |
There was a problem hiding this comment.
thought: I think it would be a good idea to either make literal take two positional args:
let literal = TokenSource::literal("wss://example.livekit.cloud", "<a pre-generated token>");Or add some sort of builder interface / explicit constructor to TokenSourceResponse:
TokenSourceResponse::new("wss://example.livekit.cloud", "<a pre-generated token>")I bring this up because while I think it's unlikely we will add extra fields to TokenSourceResponse, as thsi works today if we did add more fields it would be a breaking change. Plus, it would allow you to drop the .to_string()s.
| return; | ||
| }; | ||
|
|
||
| let options = TokenSourceFetchOptions::new() |
There was a problem hiding this comment.
nitpick: This could probably be ::default() (ie, make it derive(Default) and you get this for free), not ::new()? Or maybe there's a good reason you added a separate explicit constructor, not sure, but I think the rust defaults here would be fine for this?
There was a problem hiding this comment.
I will look into this, but sounds good. I like reading it as default() instead of just new()
| // format; here pointed at the same development token server. | ||
| let endpoint = TokenSource::endpoint( | ||
| "https://cloud-api.livekit.io/api/v2/sandbox/connection-details", | ||
| vec![("X-Sandbox-ID".to_string(), sandbox_id)], |
There was a problem hiding this comment.
question: Should this be a HashMap / something more key-value native since the order of headers shouldn't matter?
Also just an idea, I'm not sure if this is a good one though - maybe something like this could be an alternate way to design this api, and then the inner data structure becomes an implementation concern:
let endpoint = TokenSource::endpoint("https://cloud-api.livekit.io/api/v2/sandbox/connection-details")
.with_header("X-Sandbox-ID", sandbox_id);One advantage to this is you could use impl Into<String> in with_header which would allow you to pass in a &str, String, or anything else that is Into<String>.
(You could potentially also have a with_header / with_headers like with_participant_attribute / with_participant_attributes on TokenSourceFetchOptions)
There was a problem hiding this comment.
You are right, I will revisit this to not be a vector.
The .with_header is also nice to read I think.
There was a problem hiding this comment.
I might be a bit biased, but I am heavily against positional arguments in cases where it is not obvious what they represent at a glance (e.g., Vec2::new(1, 2) is obvious); I think .with_header is much cleaner here API wise.
| /// Creates empty fetch options; the server picks a default for every field. | ||
| pub fn new() -> Self { | ||
| Self::default() | ||
| } |
There was a problem hiding this comment.
Ah, I see what you did here - I would be curious what other folks think, but IMO it's fairly typical in rust to call ::default() instead of ::new(), so I would suggest dropping this alias.
There was a problem hiding this comment.
This is considered a best practice, but usually it's usually implemented the other way around (i.e., Default implementation calls new). See clippy::new_without_default for more info.
There was a problem hiding this comment.
More context: another reason this is best practice is because it allows you to use standard library functions like .unwrap_or_default() which rely on a default implementation. Standard library types like String, Vec, etc. also do this.
| // The documented endpoint contract is snake_case; the camelCase aliases | ||
| // match the leniency of the JS SDK, which parses via proto3 fromJson. | ||
| #[serde(alias = "serverUrl")] | ||
| pub server_url: String, | ||
| #[serde(alias = "participantToken")] | ||
| pub participant_token: String, |
There was a problem hiding this comment.
question: Are these aliases worth having here? It it's helpful context, the decision which was made when the token source stuff was being first conceptualized was that the wire format would always be in snake_case. Is there a case where you see needing to parse camelCase versions of these fields from within a rust context?
There was a problem hiding this comment.
I am fine with dropping it again. Maybe a bit overengineered.
There was a problem hiding this comment.
If this is necessary, Serde has a nice attribute for this that you can apply to the whole struct: #[serde(rename_all = "camelCase")]
| let response = http_client.post(self.endpoint_url.clone(), headers, body).await?; | ||
|
|
||
| if !(200..300).contains(&response.status) { |
There was a problem hiding this comment.
thought: This isn't in this code, but seeing this makes me realize we really should have a .ok() method on response which encapsulates this (200..300).contains(&response.status) check. Is that something you'd be willing to add to livekit-net? Or I could maybe do it as a follow up if you want.
There was a problem hiding this comment.
Maybe you can draft it?
There was a problem hiding this comment.
I can do it as a follow up, np.
| #[tokio::test] | ||
| async fn fetch_posts_json_and_parses_response() { | ||
| install_mock(); | ||
| let url = "https://token.test/ok"; | ||
| let endpoint = | ||
| TokenSource::endpoint(url, vec![("X-Sandbox-ID".to_string(), "sandbox-42".to_string())]); | ||
| let options = TokenSourceFetchOptions::new() | ||
| .with_room_name("my-room") | ||
| .with_participant_identity("user-123"); | ||
|
|
||
| let response = endpoint.fetch(&options).await.expect("fetch should succeed"); | ||
| assert_eq!(response.server_url, "wss://mock.livekit.cloud"); |
There was a problem hiding this comment.
These tests are excellent!!
| [development token server](https://docs.livekit.io/frontends/build/authentication/sandbox-token-server/) | ||
| for prototyping. **Not for production use.** | ||
|
|
||
| Custom credential backends can implement the `TokenSourceFixed` or `TokenSourceConfigurable` |
There was a problem hiding this comment.
suggestion: Add a section here on the difference between TokenSourceConfigurable and TokenSourceFixed. Maybe you can steal some context from here: https://github.com/livekit/client-sdk-js/tree/main#generating-a-urltoken-with-tokensource
| Err(error) => eprintln!("endpoint fetch failed: {error}"), | ||
| } | ||
| } |
There was a problem hiding this comment.
suggestion: Consider adding an example of a custom token source in here just to be thorough.
There was a problem hiding this comment.
The implementation does not have a custom token source yet. I tried to push back things that can be done later, but I also understand if this is supposed to be complete.
There was a problem hiding this comment.
To be clear, what I meant by "custom token source" is something like the below in the example:
struct MyTokenSource;
impl TokenSourceConfigurable for MyTokenSource {
async fn fetch(
&self,
options: &TokenSourceFetchOptions,
) -> TokenSourceResult<TokenSourceResponse> {
// Do something unusual in here, maybe like get tokens from like a file?
// Or maybe like reading them from the keychain or something like that could be an interesting demo?
Ok(TokenSourceResponse { /* fields here */ })
}
}
// Then in main:
let custom = MyTokenSource {};
match custom.fetch(&options).await {
Ok(response) => println!(
"custom token source: server_url={} participant_token={}",
response.server_url, response.participant_token
),
Err(error) => eprintln!("custom token source fetch failed: {error}"),
}| /// The return type of [`TokenSource::endpoint`]. | ||
| pub struct TokenSourceEndpoint { | ||
| endpoint_url: String, | ||
| headers: Vec<(String, String)>, | ||
| } | ||
|
|
||
| #[async_trait] | ||
| impl TokenSourceConfigurable for TokenSourceEndpoint { | ||
| async fn fetch( | ||
| &self, | ||
| options: &TokenSourceFetchOptions, | ||
| ) -> TokenSourceResult<TokenSourceResponse> { | ||
| let request = TokenSourceRequest::from(options); | ||
|
|
There was a problem hiding this comment.
thought: In the web token source implementation, there is caching which is done to avoid re-fetching a new token when the previous token is valid. IMO, this is really useful, and can be a significant slowdown when a user is initially connecting to a room to not have this. It also allows for pre-emptive token fetching (either on app load, or after a disconnect to make repeated connect / disconnect cycles fast).
I know how exactly this caching is done differs between the implementations (Web does it within the TokenSourceEndpoint via an internal abstract class it extends, swift has a TokenSourceCached, etc) but IMO it would be worth coming up with a strategy of some sort on how to handle this (I don't have a strong preference how it should work).
t's somewhat arguable whether adding it would be a breaking change in the interface IMO, adding caching later on obviously wouldn't break the existing interface but also on the other hand, a user may build around assuming that a token will always be regenerated for each room connection and adding caching would break that assumption. That's why I would be in favor of adding this now, because then there's no risk of a user accidentally building around this assumption and making adding it later in a fully backwards compatible way challenging.
There was a problem hiding this comment.
Thanks for your opinion, I wanted to implement it later. Let me see how I can add it now already though.
One thing I want to note is that for agent use cases, I have experienced caching causing issues. If you leave a room and there was an agent dispatched, calling again with caching active will put you in the same room. But because you left before, the agent is being torn down and is also not joining again. So for agent use cases, I think caching should be discouraged with our current logic on dispatch.
ladvoc
left a comment
There was a problem hiding this comment.
Looking clean overall! Just some minor comments and suggestions.
| struct MockHttp; | ||
|
|
||
| #[async_trait::async_trait] | ||
| impl livekit_net::HttpClient for MockHttp { |
There was a problem hiding this comment.
note: Probably outside the scope of this PR, but this looks like a generally useful testing utility that could be moved to the livekit-net crate and exposed conditionally under a mock feature.
| // format; here pointed at the same development token server. | ||
| let endpoint = TokenSource::endpoint( | ||
| "https://cloud-api.livekit.io/api/v2/sandbox/connection-details", | ||
| vec![("X-Sandbox-ID".to_string(), sandbox_id)], |
There was a problem hiding this comment.
I might be a bit biased, but I am heavily against positional arguments in cases where it is not obvious what they represent at a glance (e.g., Vec2::new(1, 2) is obvious); I think .with_header is much cleaner here API wise.
| // The documented endpoint contract is snake_case; the camelCase aliases | ||
| // match the leniency of the JS SDK, which parses via proto3 fromJson. | ||
| #[serde(alias = "serverUrl")] | ||
| pub server_url: String, | ||
| #[serde(alias = "participantToken")] | ||
| pub participant_token: String, |
There was a problem hiding this comment.
If this is necessary, Serde has a nice attribute for this that you can apply to the whole struct: #[serde(rename_all = "camelCase")]
|
|
||
| /// Adds the given attributes to the participant attributes, keeping any set previously. | ||
| /// A key that was already set is overwritten with its new value. | ||
| pub fn with_participant_attributes(mut self, value: HashMap<String, String>) -> Self { |
There was a problem hiding this comment.
suggestion: To stay in line with keeping these methods generic, consider allowing this to work with any type providing string key-value pairs:
pub fn with_participant_attributes(
mut self,
value: impl IntoIterator<
Item = (impl Into<String>, impl Into<String>)
>,
) -> Self {
self.participant_attributes
.get_or_insert_with(HashMap::new)
.extend(value.into_iter().map(|(k, v)| (k.into(), v.into())));
self
}At the call site, the user has flexibility:
// 1. Array of key-value pairs
builder.with_participant_attributes([
("name", "alice"),
("role", "host"),
]);
// 2. Vec of key-value pairs
builder.with_participant_attributes(vec![
("name", "alice"),
("role", "host"),
]);
// 3. Hash map
let mut attrs = HashMap::new();
attrs.insert("name".to_string(), "alice".to_string());
attrs.insert("role".to_string(), "host".to_string());
builder.with_participant_attributes(attrs);| } | ||
|
|
||
| #[derive(serde::Serialize)] | ||
| struct RoomConfig { |
There was a problem hiding this comment.
suggestion: Room config is a massive nested Protobuf (definition), and while it probably doesn't make sense to expose it here and introduce a dependency on livekit-protocol there should be a comment indicating it is non-exhaustive.
As an aside, we have this same situation in the access token module exposed over UniFFI and I opted to do the same thing there.
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| use crate::error::TokenSourceError; |
There was a problem hiding this comment.
nitpick: Consider combining into a single use statement:
use crate::{
request::{TokenSourceFetchOptions, ...},
response::{TokenSourceResponse, ...},
...
};| use livekit_net::{Header, HttpClientExt}; | ||
|
|
||
| const DEVELOPMENT_TOKEN_SERVER_ENDPOINT_URL: &str = | ||
| "https://cloud-api.livekit.io/api/v2/sandbox/connection-details"; |
There was a problem hiding this comment.
@1egoman, I think there is some ongoing effort to transition to livekit.com from livekit.io. Do you know anything about this and whether we should be pointing to livekit.com for future compatibility?
There was a problem hiding this comment.
I don't, no. IMO though as part of that migration (assuming it is not just for marketing facing things) all existing api routes would need to be backwards compatible to not break old web apps so I think this is fine as is.
|
|
||
| /// Factory for the token sources shipped with this crate. Not instantiable; | ||
| /// use the associated functions to construct a concrete source. | ||
| pub enum TokenSource {} |
There was a problem hiding this comment.
I am not opposed to leaving it like this, but I will note this factory pattern is not common in Rust. A more common pattern would be to expose these as freestanding helper functions, and the user would typically reference them with the module path instead of bringing them in with use so it is clear where they come from. Example of what this would look like for the user:
livekit_token_source::literal(...)Coming from primarily OO languages, this might seem weird, but Rust doesn't have anything against freestanding functions in a public API. Tokio, for example, uses this pattern in several places: see mpsc.
|
Note: before publishing this crate, I need to setup trusted publishing to prevent a recurrence of last week's release issue; on my list, just haven't gotten around to it yet. |
Before you submit your PR
Make sure the following is true before submitting your PR:
PR description
This PR adds the Token Source concept as an independent crate.
Testing
Ideally, unit test the code you add, but ensure you're not repeating existing test cases. Use as many already written scaffolding, utilities as possible; write your own, when needed. If external services, APIs, tokens are required (e.g., running an LK server instance), provide the necessary information. Make sure your tests perform useful, context-aware assertions and do not simply emulate "happy paths".
Async
We want the project to be runtime-agnostic, so please reuse what's already in livekit-runtime and feel free to add anything missing. It's ok to use Tokio directly, when writing unit tests, if necessary. When testing, do not use artificial delays for the state to "catch up"; instead, respect the event flow and subscribe properly using channels or other mechanisms.