Skip to main content

caravan_rpc/
lib.rs

1//! Runtime SDK for the [Caravan](https://github.com/paulxiep/caravan)
2//! application-definition compiler.
3//!
4//! A user declares a seam-interface trait, marks it with [`wagon`], registers a
5//! concrete implementation via [`provide`], and dispatches through [`client`].
6//! Dispatch mode (inproc / http / lambda) is read from the
7//! `CARAVAN_RPC_PEERS` env var at the call site; when the env var is unset,
8//! `client::<dyn I>()` returns the registered `Arc<dyn I>` directly with no
9//! overhead (no-config inertness).
10//!
11//! See <https://github.com/paulxiep/caravan/blob/main/docs/poc_rpc_sdk.md> for
12//! the wire contract and per-language surface.
13//!
14//! # M2 status
15//!
16//! 0.1.0 ships the runtime building blocks: codec (`codec`), peer-table
17//! parsing (`peers`), error types (`errors`), HTTP client dispatchers
18//! (`dispatch`, behind the `client` feature). The proc-macro that turns
19//! `#[wagon]` into server + client adapters lands in M2 Session 3+. Until
20//! then, `client::<dyn T>()` returns the inproc-registered impl regardless of
21//! peer-table mode — switching to HTTP happens only after the proc-macro
22//! wires per-trait HTTP adapter discovery.
23//!
24//! Lambda mode panics with an M7 pointer (forward-compat marker).
25//!
26//! ```ignore
27//! use std::sync::Arc;
28//! use caravan_rpc::{wagon, provide, client};
29//!
30//! #[wagon]
31//! pub trait Embedder: Send + Sync {
32//!     fn embed(&self, text: &str) -> Vec<f32>;
33//! }
34//!
35//! struct InMemoryEmbedder;
36//! impl Embedder for InMemoryEmbedder {
37//!     fn embed(&self, _text: &str) -> Vec<f32> { vec![0.0; 8] }
38//! }
39//!
40//! // startup
41//! provide::<dyn Embedder>(Arc::new(InMemoryEmbedder));
42//!
43//! // call site
44//! let v = client::<dyn Embedder>().embed("hello");
45//! assert_eq!(v.len(), 8);
46//! ```
47
48#![forbid(unsafe_code)]
49
50// `#[wagon]`-generated code emits `::caravan_rpc::...` paths. Inside this
51// crate we re-alias the crate's own name so the macro works on traits
52// declared internally (e.g. the `resources::BlobStore` / `MessageQueue`
53// seams shipped from caravan-rpc itself).
54extern crate self as caravan_rpc;
55
56use std::any::{Any, TypeId};
57use std::collections::HashMap;
58use std::sync::{Arc, OnceLock, RwLock};
59
60pub use caravan_rpc_macros::wagon;
61
62pub mod codec;
63pub mod errors;
64pub mod peers;
65pub mod resources;
66
67#[cfg(feature = "client")]
68pub mod dispatch;
69
70#[cfg(feature = "server")]
71pub mod server;
72
73pub use errors::{RpcError, RpcRemoteError, RpcTransportError};
74pub use peers::{PeerEntry, peer_for};
75#[cfg(feature = "resources-rabbit")]
76pub use resources::RabbitMQQueue;
77#[cfg(feature = "resources-redis")]
78pub use resources::RedisStreamQueue;
79pub use resources::{
80    BlobError, BlobStore, LocalFsBlobStore, MessageQueue, QueueError, auto_register_resources,
81};
82#[cfg(feature = "resources-aws")]
83pub use resources::{S3BlobStore, SqsQueue};
84
85/// Internal re-exports for use by `#[wagon]`-generated code only. Lets the
86/// user's crate depend solely on `caravan-rpc`; the macro reaches in here
87/// rather than spelling `::serde_json::...` / `::axum::...` (which would
88/// require the user to add those crates to their own Cargo.toml).
89///
90/// Not a stable public API — names may change without notice.
91#[doc(hidden)]
92pub mod __macro_support {
93    pub use async_trait;
94    #[cfg(feature = "server")]
95    pub use axum;
96    pub use inventory;
97    pub use serde_json;
98}
99
100/// Factory entry for a `#[wagon]` trait's remote client adapter.
101///
102/// Macro-generated code submits one of these per full-codegen trait via
103/// `inventory::submit!` so `client::<dyn T>()` can discover the
104/// trait-specific client constructor at runtime, indexed by `TypeId`.
105///
106/// `construct` accepts a `PeerEntry` (HTTP or Lambda) and returns the
107/// adapter wrapped as `Arc<dyn T>` then erased into `Box<dyn Any + Send +
108/// Sync>` (because `Arc<dyn T>: Any` for `T: 'static`). The SDK downcasts
109/// back to `Arc<T>` in `client::<T>()`. The macro-generated adapter
110/// internally branches per `PeerEntry` variant to pick HTTP-bearer or
111/// SigV4 dispatch at each call.
112pub struct HttpAdapterFactory {
113    pub interface_name: &'static str,
114    pub type_id_fn: fn() -> std::any::TypeId,
115    pub construct: fn(peer: PeerEntry) -> Box<dyn Any + Send + Sync>,
116}
117
118inventory::collect!(HttpAdapterFactory);
119
120fn lookup_http_factory<T: ?Sized + 'static>() -> Option<&'static HttpAdapterFactory> {
121    let want = TypeId::of::<T>();
122    inventory::iter::<HttpAdapterFactory>
123        .into_iter()
124        .find(|f| (f.type_id_fn)() == want)
125}
126
127/// Factory entry for a `#[wagon]` trait's server-side router. Mirrors
128/// [`HttpAdapterFactory`] but for the server direction.
129///
130/// Macro-generated code submits one of these per full-codegen trait via
131/// `inventory::submit!`. [`run_or_serve`] iterates this collection by
132/// interface name to find the right router builder when starting in
133/// peer mode.
134///
135/// `build_router_from_registry` is macro-emitted and does the
136/// trait-erased work of: `try_client::<dyn Trait>()` for the impl,
137/// then `build_<trait>_router(impl)` to produce the axum router.
138#[cfg(feature = "server")]
139pub struct HttpServerFactory {
140    pub interface_name: &'static str,
141    pub build_router_from_registry:
142        fn() -> Result<crate::__macro_support::axum::Router, &'static str>,
143}
144
145#[cfg(feature = "server")]
146inventory::collect!(HttpServerFactory);
147
148/// Run the user's main, OR start a peer HTTP server, based on the
149/// `CARAVAN_RPC_ROLE` env var.
150///
151/// **Inertness**: when the env var is unset or empty, this just awaits
152/// `user_main` and returns — no overhead, no behavior change.
153///
154/// **Peer mode**: when `CARAVAN_RPC_ROLE=peer-<InterfaceName>`,
155/// `user_main` is NOT called. Instead, the SDK:
156///   1. Looks up the macro-emitted [`HttpServerFactory`] for the named
157///      interface (via inventory).
158///   2. Calls `build_router_from_registry` which (a) finds the
159///      `provide()`-registered impl in the inproc registry and (b)
160///      builds the axum router using the macro-generated
161///      `build_<trait>_router(impl)`.
162///   3. Binds on `CARAVAN_RPC_BIND_ADDR` (default `0.0.0.0:8080`) and
163///      `serve_forever`s.
164///
165/// Caller contract: the user's setup code (including `provide()` calls
166/// for all #[wagon] traits) must run BEFORE `run_or_serve` is awaited.
167/// Typical pattern:
168///
169/// ```ignore
170/// #[tokio::main]
171/// async fn main() -> Result<()> {
172///     let state = AppState::from_config(...).await?;  // calls provide() inside
173///     caravan_rpc::run_or_serve(|| async move {
174///         // user's normal app startup — only runs in non-peer mode.
175///         run_chat_server(state).await
176///     }).await
177/// }
178/// ```
179#[cfg(feature = "server")]
180pub async fn run_or_serve<F, Fut>(user_main: F) -> Result<(), RpcError>
181where
182    F: FnOnce() -> Fut,
183    Fut: std::future::Future<Output = Result<(), RpcError>>,
184{
185    let role = std::env::var("CARAVAN_RPC_ROLE").unwrap_or_default();
186    if let Some(iface_name) = role.strip_prefix("peer-") {
187        let factory = inventory::iter::<HttpServerFactory>
188            .into_iter()
189            .find(|f| f.interface_name == iface_name)
190            .unwrap_or_else(|| {
191                panic!(
192                    "caravan-rpc: CARAVAN_RPC_ROLE={role:?} but no HttpServerFactory \
193                     registered for interface {iface_name:?}. Did you mark the trait \
194                     with #[wagon] and have your impl crate compiled into this binary?"
195                )
196            });
197        let router = (factory.build_router_from_registry)().unwrap_or_else(|msg| {
198            panic!("caravan-rpc: peer {iface_name} failed to build router: {msg}")
199        });
200        // Lambda detection (M7). When AWS sets AWS_LAMBDA_RUNTIME_API, hand
201        // the axum router to lambda_http::run instead of binding a TCP
202        // socket. lambda_http translates Function URL events into
203        // tower::Service calls on the same router we use for HTTP peers.
204        if std::env::var("AWS_LAMBDA_RUNTIME_API").is_ok() {
205            eprintln!("caravan peer {iface_name} serving via Lambda runtime");
206            lambda_http::run(router)
207                .await
208                .expect("lambda_http::run returned error");
209            return Ok(());
210        }
211        let addr: std::net::SocketAddr = std::env::var("CARAVAN_RPC_BIND_ADDR")
212            .unwrap_or_else(|_| "0.0.0.0:8080".to_string())
213            .parse()
214            .expect("CARAVAN_RPC_BIND_ADDR must parse as SocketAddr");
215        eprintln!("caravan peer {iface_name} serving on {addr}");
216        server::serve_forever(addr, router)
217            .await
218            .expect("serve_forever returned error");
219        Ok(())
220    } else {
221        user_main().await
222    }
223}
224
225/// Version of this crate.
226pub const VERSION: &str = env!("CARGO_PKG_VERSION");
227
228/// Process-global inproc registry mapping a seam trait's [`TypeId`] to its
229/// `Arc<dyn T>` impl.
230///
231/// Stored as `Box<dyn Any + Send + Sync>` so we can key by any trait object's
232/// `TypeId`. The stored value is always an `Arc<T>` (with `T: ?Sized`); the
233/// downcast in [`client`] reconstructs that exact type.
234type Registry = RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>;
235
236fn registry() -> &'static Registry {
237    static R: OnceLock<Registry> = OnceLock::new();
238    R.get_or_init(|| RwLock::new(HashMap::new()))
239}
240
241/// Register `impl_` as the inproc provider for trait object `T`.
242///
243/// Call once per process at startup (worker entry, CLI `main()`) before any
244/// `client::<dyn T>()` call. Re-registering an interface replaces the prior
245/// impl (last-write-wins) — intentional for test isolation; production code
246/// should call `provide` once per interface.
247///
248/// ```ignore
249/// provide::<dyn Embedder>(Arc::new(FastEmbedImpl::new()?));
250/// ```
251pub fn provide<T: ?Sized + Send + Sync + 'static>(impl_: Arc<T>) {
252    let mut g = registry().write().expect("caravan-rpc registry poisoned");
253    g.insert(
254        TypeId::of::<T>(),
255        Box::new(impl_) as Box<dyn Any + Send + Sync>,
256    );
257}
258
259/// Return an `Arc<dyn T>` to dispatch through.
260///
261/// Behavior depends on `CARAVAN_RPC_PEERS[interface]`:
262/// * Unset or `inproc` → the locally `provide()`-ed impl (zero-overhead).
263/// * `http` AND the trait was full-codegen-expanded by `#[wagon]` (so an
264///   inventory factory exists) → an `Arc<<Trait>HttpClient>` whose every
265///   method call goes over the wire.
266/// * `http` but no inventory factory (e.g., `#[wagon(identity)]` trait) →
267///   falls back to the local impl. Logged once at startup so misconfigs
268///   are visible. Documented limitation: identity-marked traits don't
269///   honor mode flips.
270/// * `lambda` → panic with M7 pointer.
271///
272/// Panics if no impl is registered AND no http factory exists for `T`.
273/// Use [`try_client`] for optional seams.
274pub fn client<T: ?Sized + Send + Sync + 'static>() -> Arc<T> {
275    try_client::<T>().unwrap_or_else(|| {
276        panic!(
277            "no impl registered for type {}; call provide::<{}>(Arc::new(impl)) at startup",
278            std::any::type_name::<T>(),
279            std::any::type_name::<T>()
280        )
281    })
282}
283
284/// Return an `Arc<dyn T>` to dispatch through, or `None` if no impl is
285/// available (neither locally `provide()`-ed nor wired via HTTP through
286/// `#[wagon]`'s inventory factory).
287///
288/// Use this for seams that are conditionally enabled at runtime (e.g. an
289/// optional reranker). For seams that must always be present, prefer the
290/// panicking [`client`] for a clearer error message at startup.
291///
292/// Dispatch-mode selection mirrors [`client`]: HTTP mode + an inventory
293/// factory → returns the macro-generated `<Trait>HttpClient`; otherwise
294/// → returns the registered local impl (inproc).
295pub fn try_client<T: ?Sized + Send + Sync + 'static>() -> Option<Arc<T>> {
296    // 1. If an HTTP factory exists for T (i.e., the trait was full-
297    //    codegen-expanded by `#[wagon]`), consult the peer table.
298    if let Some(factory) = lookup_http_factory::<T>() {
299        // Self-call guard: when this process is itself running as the
300        // peer for `T` (CARAVAN_RPC_ROLE=peer-<factory.interface_name>),
301        // bypass the HTTP factory so the macro-emitted router's
302        // `try_client::<dyn T>()` returns the locally `provide()`-d impl
303        // instead of an HttpClient pointed back at us — that would loop.
304        // Crucially the consumer entry's binary AND the peer share the
305        // same CARAVAN_RPC_PEERS (peers reuse the entry's image); the
306        // role-env is the only distinguisher.
307        let serving_self = std::env::var("CARAVAN_RPC_ROLE")
308            .ok()
309            .and_then(|role| {
310                role.strip_prefix("peer-")
311                    .map(|iface| iface == factory.interface_name)
312            })
313            .unwrap_or(false);
314        if !serving_self {
315            match peer_for(factory.interface_name) {
316                Some(entry @ PeerEntry::Http { .. })
317                | Some(entry @ PeerEntry::Lambda { .. }) => {
318                    let boxed = (factory.construct)(entry);
319                    return Some(
320                        *boxed.downcast::<Arc<T>>().expect(
321                            "caravan-rpc: HttpAdapterFactory.construct returned wrong type",
322                        ),
323                    );
324                }
325                // Inproc or absent → fall through to local registry lookup.
326                _ => {}
327            }
328        }
329    }
330
331    // 2. Local registry lookup. Covers inproc mode + identity-marked
332    //    traits + http mode without factory + lambda without factory
333    //    (the last two are correctness-acceptable per `client` docs).
334    let g = registry().read().expect("caravan-rpc registry poisoned");
335    g.get(&TypeId::of::<T>()).map(|entry| {
336        entry
337            .downcast_ref::<Arc<T>>()
338            .expect("caravan-rpc registry type mismatch (internal bug)")
339            .clone()
340    })
341}
342
343/// Whether an impl has been registered for trait object `T`.
344///
345/// Slightly cheaper than [`try_client`] when the caller doesn't need the impl
346/// itself (e.g. health checks). Subject to TOCTOU — prefer `try_client` in
347/// dispatch paths.
348pub fn is_provided<T: ?Sized + Send + Sync + 'static>() -> bool {
349    let g = registry().read().expect("caravan-rpc registry poisoned");
350    g.contains_key(&TypeId::of::<T>())
351}
352
353/// Reset the registry. Intended for test isolation only; production code
354/// should `provide()` once and leave the registry alone for the process
355/// lifetime.
356#[doc(hidden)]
357pub fn __clear_registry_for_tests() {
358    let mut g = registry().write().expect("caravan-rpc registry poisoned");
359    g.clear();
360}