1use std::collections::HashMap;
2use std::sync::Arc;
3use std::sync::atomic::{AtomicBool, Ordering};
4use std::time::Duration;
5
6use anyhow::{Context, Result};
7use http::StatusCode;
8use rivet_envoy_client::config::{ActorName as EnvoyActorName, EnvoyConfig};
9use rivet_envoy_client::envoy::start_envoy as start_envoy_client;
10use rivet_envoy_client::handle::EnvoyHandle;
11use rivet_envoy_client::protocol;
12use rivetkit_shared_types::serverless_metadata::{
13 ActorName, ServerlessMetadataEnvoy, ServerlessMetadataEnvoyKind, ServerlessMetadataPayload,
14};
15use serde::Serialize;
16use serde_json::json;
17use tokio::sync::{Mutex as TokioMutex, mpsc};
18use tokio_util::sync::CancellationToken;
19use url::Url;
20
21use crate::actor::factory::ActorFactory;
22#[cfg(feature = "native-runtime")]
23use crate::engine_process::{EngineProcessManager, EngineResolverConfig};
24use crate::registry::{
25 CoreEnvoyHandle, CoreEnvoyStatus, RegistryCallbacks, RegistryDispatcher, ServeConfig,
26 should_manage_engine,
27};
28use crate::runtime::RuntimeSpawner;
29use crate::time::{sleep, timeout};
30
31const DEFAULT_BASE_PATH: &str = "/api/rivet";
32const SSE_PING_INTERVAL: Duration = Duration::from_secs(1);
33const SSE_PING_FRAME: &[u8] = b"event: ping\ndata:\n\n";
34const SSE_STOPPING_FRAME: &[u8] = b"event: stopping\ndata:\n\n";
35const SHUTDOWN_DRAIN_TIMEOUT: Duration = Duration::from_secs(20);
39
40#[derive(Clone)]
41pub struct CoreServerlessRuntime {
42 settings: Arc<ServerlessSettings>,
43 dispatcher: Arc<RegistryDispatcher>,
44 envoy: Arc<TokioMutex<Option<EnvoyHandle>>>,
45 #[cfg(feature = "native-runtime")]
46 _engine_process: Arc<TokioMutex<Option<EngineProcessManager>>>,
47 shutting_down: Arc<AtomicBool>,
48}
49
50#[derive(Clone, Debug)]
51struct ServerlessSettings {
52 version: u32,
53 configured_endpoint: String,
54 configured_namespace: String,
55 base_path: String,
56 package_version: String,
57 client_endpoint: Option<String>,
58 client_namespace: Option<String>,
59 client_token: Option<String>,
60 validate_endpoint: bool,
61 max_start_payload_bytes: usize,
62 cache_envoy: bool,
63}
64
65#[derive(Debug)]
66pub struct ServerlessRequest {
67 pub method: String,
68 pub url: String,
69 pub headers: HashMap<String, String>,
70 pub body: Vec<u8>,
71 pub cancel_token: CancellationToken,
72}
73
74#[derive(Debug)]
75pub struct ServerlessResponse {
76 pub status: u16,
77 pub headers: HashMap<String, String>,
78 pub body: mpsc::UnboundedReceiver<Result<Vec<u8>, ServerlessStreamError>>,
79}
80
81#[derive(Clone, Debug, Serialize)]
82pub struct ServerlessStreamError {
83 pub group: String,
84 pub code: String,
85 pub message: String,
86}
87
88#[derive(Debug)]
89struct StartHeaders {
90 endpoint: String,
91 token: Option<String>,
92 pool_name: String,
93 namespace: String,
94}
95
96#[derive(Debug, Serialize)]
97struct ServerlessErrorBody<'a> {
98 group: &'a str,
99 code: &'a str,
100 message: String,
101 metadata: serde_json::Value,
102}
103
104#[derive(rivet_error::RivetError, Serialize)]
105#[error("request", "invalid", "Invalid request.", "Invalid request: {reason}")]
106struct InvalidRequest {
107 reason: String,
108}
109
110#[derive(rivet_error::RivetError, Serialize)]
111#[error(
112 "config",
113 "endpoint_mismatch",
114 "Endpoint mismatch.",
115 "Endpoint mismatch: expected \"{expected}\", received \"{received}\""
116)]
117struct EndpointMismatch {
118 expected: String,
119 received: String,
120}
121
122#[derive(rivet_error::RivetError, Serialize)]
123#[error(
124 "config",
125 "namespace_mismatch",
126 "Namespace mismatch.",
127 "Namespace mismatch: expected \"{expected}\", received \"{received}\""
128)]
129struct NamespaceMismatch {
130 expected: String,
131 received: String,
132}
133
134#[derive(rivet_error::RivetError, Serialize)]
135#[error(
136 "message",
137 "incoming_too_long",
138 "Incoming message too long.",
139 "Incoming message too long. Exceeded limit of {limit} bytes."
140)]
141struct IncomingMessageTooLong {
142 limit: usize,
143}
144
145#[derive(rivet_error::RivetError, Serialize)]
146#[error(
147 "registry",
148 "shut_down",
149 "Registry is shut down.",
150 "Registry is shut down; no new requests can be accepted."
151)]
152struct RuntimeShutDown;
153
154impl CoreServerlessRuntime {
155 pub(crate) async fn new(
156 factories: HashMap<String, Arc<ActorFactory>>,
157 config: ServeConfig,
158 ) -> Result<Self> {
159 #[cfg(feature = "native-runtime")]
160 let engine_process = if should_manage_engine(&config.endpoint, config.engine_spawn)? {
161 Some(
162 EngineProcessManager::start_or_reuse(EngineResolverConfig::from_parts(
163 &config.endpoint,
164 config.engine_binary_path.clone(),
165 config.engine_host.clone(),
166 config.engine_port,
167 config.engine_auto_download,
168 ))
169 .await?,
170 )
171 } else {
172 None
173 };
174 #[cfg(not(feature = "native-runtime"))]
175 if should_manage_engine(&config.endpoint, config.engine_spawn)? {
176 anyhow::bail!("engine process spawning requires the `native-runtime` feature");
177 }
178
179 let dispatcher = Arc::new(RegistryDispatcher::new(
180 factories,
181 config.handle_inspector_http_in_runtime,
182 ));
183 let base_path = normalize_base_path(config.serverless_base_path.as_deref());
184 crate::metrics_endpoint::record_rivetkit_info(
185 config.serverless_package_version.clone(),
186 config.version,
187 "serverless",
188 config.pool_name.clone(),
189 );
190
191 Ok(Self {
192 settings: Arc::new(ServerlessSettings {
193 version: config.version,
194 configured_endpoint: config.endpoint,
195 configured_namespace: config.namespace,
196 base_path,
197 package_version: config.serverless_package_version,
198 client_endpoint: config.serverless_client_endpoint,
199 client_namespace: config.serverless_client_namespace,
200 client_token: config.serverless_client_token,
201 validate_endpoint: config.serverless_validate_endpoint,
202 max_start_payload_bytes: config.serverless_max_start_payload_bytes,
203 cache_envoy: config.serverless_cache_envoy,
204 }),
205 dispatcher,
206 envoy: Arc::new(TokioMutex::new(None)),
207 #[cfg(feature = "native-runtime")]
208 _engine_process: Arc::new(TokioMutex::new(engine_process)),
209 shutting_down: Arc::new(AtomicBool::new(false)),
210 })
211 }
212
213 pub async fn shutdown(&self) {
220 self.shutting_down.store(true, Ordering::Release);
221 let handle = { self.envoy.lock().await.take() };
222 let Some(handle) = handle else { return };
223 match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)).await {
224 Ok(()) => {}
225 Err(_) => {
226 tracing::warn!(
227 "serverless runtime envoy drain exceeded timeout; forcing immediate stop"
228 );
229 handle.shutdown(true);
230 handle.wait_stopped().await;
231 }
232 }
233 }
234
235 pub async fn active_envoy_actor_count(&self) -> Option<usize> {
236 self.active_envoy_status()
237 .await
238 .map(|status| status.active_actor_count)
239 }
240
241 pub async fn active_envoy_status(&self) -> Option<CoreEnvoyStatus> {
242 self.envoy
243 .lock()
244 .await
245 .as_ref()
246 .map(|handle| CoreEnvoyHandle::new(handle.clone()).status())
247 }
248
249 pub async fn active_envoy_actor_stop_threshold_ms(&self) -> Option<i64> {
250 let handle = self.envoy.lock().await.as_ref().cloned()?;
251 CoreEnvoyHandle::new(handle).actor_stop_threshold_ms().await
252 }
253
254 pub fn max_request_body_bytes(&self) -> usize {
256 self.settings.max_start_payload_bytes
257 }
258
259 pub fn incoming_too_long_response(&self) -> ServerlessResponse {
261 let error = IncomingMessageTooLong {
262 limit: self.settings.max_start_payload_bytes,
263 }
264 .build();
265 error_response(error)
266 }
267
268 pub fn invalid_request_response(&self, reason: impl Into<String>) -> ServerlessResponse {
270 let error = InvalidRequest {
271 reason: reason.into(),
272 }
273 .build();
274 error_response(error)
275 }
276
277 pub async fn handle_request(&self, req: ServerlessRequest) -> ServerlessResponse {
278 let cors = cors_headers(&req);
279 match self.handle_request_inner(req).await {
280 Ok(mut response) => {
281 apply_cors(&mut response.headers, cors);
282 response
283 }
284 Err(error) => {
285 let mut response = error_response(error);
286 apply_cors(&mut response.headers, cors);
287 response
288 }
289 }
290 }
291
292 async fn handle_request_inner(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
293 let path = route_path(&self.settings.base_path, &req.url)?;
294 match (req.method.as_str(), path.as_str()) {
295 ("GET", "") | ("GET", "/") => Ok(text_response(
296 StatusCode::OK,
297 "text/plain; charset=utf-8",
298 "This is a RivetKit server.\n\nLearn more at https://rivet.dev",
299 )),
300 ("GET", "/health") => {
301 let runtime_healthy = {
306 let guard = self.envoy.lock().await;
307 guard
308 .as_ref()
309 .map(|handle| handle.is_ping_healthy())
310 .unwrap_or(true)
311 };
312 if runtime_healthy {
313 Ok(json_response(
314 StatusCode::OK,
315 json!({
316 "status": "ok",
317 "runtime": "rivetkit",
318 "version": self.settings.package_version,
319 }),
320 ))
321 } else {
322 Ok(json_response(
323 StatusCode::SERVICE_UNAVAILABLE,
324 json!({
325 "status": "engine_ping_stale",
326 "runtime": "rivetkit",
327 "version": self.settings.package_version,
328 }),
329 ))
330 }
331 }
332 ("GET", "/metadata") => Ok(self.metadata_response()),
333 ("GET", "/metrics") => Ok(metrics_response(&req.headers)),
334 ("GET", "/start") | ("POST", "/start") => self.start_response(req).await,
335 ("OPTIONS", _) => Ok(bytes_response(
336 StatusCode::NO_CONTENT,
337 HashMap::new(),
338 Vec::new(),
339 )),
340 _ => Ok(text_response(
341 StatusCode::NOT_FOUND,
342 "text/plain; charset=utf-8",
343 "Not Found (RivetKit)",
344 )),
345 }
346 }
347
348 async fn start_response(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
349 let headers = parse_start_headers(&req.headers)?;
350 self.validate_start_headers(&headers)?;
351 crate::metrics_endpoint::record_rivetkit_info(
352 self.settings.package_version.clone(),
353 self.settings.version,
354 "serverless",
355 headers.pool_name.clone(),
356 );
357 if req.body.len() > self.settings.max_start_payload_bytes {
358 return Err(IncomingMessageTooLong {
359 limit: self.settings.max_start_payload_bytes,
360 }
361 .build());
362 }
363
364 let handle = self.ensure_envoy(&headers).await?;
365 let payload = req.body;
366 let actor_start = handle.decode_serverless_actor_start(&payload)?;
367 let cancel_token = req.cancel_token;
368 let cache_envoy = self.settings.cache_envoy;
369 let (tx, rx) = mpsc::unbounded_channel();
370 let _ = tx.send(Ok(SSE_PING_FRAME.to_vec()));
371
372 RuntimeSpawner::spawn(async move {
373 let shutdown_handle = handle.clone();
374 let result = tokio::select! {
375 _ = cancel_token.cancelled() => {
376 if !cache_envoy {
377 shutdown_handle.shutdown_and_wait(false).await;
378 }
379 return;
380 }
381 result = handle.start_serverless_actor(&payload) => result,
382 };
383 if let Err(error) = result {
384 let error = stream_error(error);
385 let _ = tx.send(Err(error));
386 if !cache_envoy {
387 handle.shutdown_and_wait(false).await;
388 }
389 return;
390 }
391
392 loop {
393 tokio::select! {
394 _ = cancel_token.cancelled() => {
395 break;
396 }
397 _ = handle.wait_actor_registered_then_stopped(&actor_start.actor_id, actor_start.generation) => {
398 let _ = tx.send(Ok(SSE_STOPPING_FRAME.to_vec()));
399 break;
400 }
401 _ = sleep(SSE_PING_INTERVAL) => {
402 if tx.send(Ok(SSE_PING_FRAME.to_vec())).is_err() {
403 break;
404 }
405 }
406 }
407 }
408
409 if !cache_envoy {
410 handle.shutdown_and_wait(false).await;
411 }
412 });
413
414 Ok(ServerlessResponse {
415 status: StatusCode::OK.as_u16(),
416 headers: HashMap::from([
417 ("content-type".to_owned(), "text/event-stream".to_owned()),
418 ("cache-control".to_owned(), "no-cache".to_owned()),
419 ("connection".to_owned(), "keep-alive".to_owned()),
420 ]),
421 body: rx,
422 })
423 }
424
425 fn metadata_response(&self) -> ServerlessResponse {
426 let actor_names = self
427 .dispatcher
428 .build_actor_metadata_map()
429 .into_iter()
430 .map(|(name, metadata)| {
431 (
432 name,
433 ActorName {
434 metadata: Some(metadata),
435 },
436 )
437 })
438 .collect::<HashMap<_, _>>();
439
440 let payload = ServerlessMetadataPayload {
441 runtime: "rivetkit".to_owned(),
442 version: self.settings.package_version.clone(),
443 envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
444 actor_names,
445 envoy: Some(ServerlessMetadataEnvoy {
446 kind: Some(ServerlessMetadataEnvoyKind::Serverless {}),
447 version: Some(self.settings.version),
448 }),
449 runner: None,
450 client_endpoint: self.settings.client_endpoint.clone(),
451 client_namespace: self.settings.client_namespace.clone(),
452 client_token: self.settings.client_token.clone(),
453 };
454
455 let response = serde_json::to_value(payload).unwrap_or_else(|_| json!({}));
456
457 json_response(StatusCode::OK, response)
458 }
459
460 fn validate_start_headers(&self, headers: &StartHeaders) -> Result<()> {
461 if self.settings.validate_endpoint {
462 if !endpoints_match(&headers.endpoint, &self.settings.configured_endpoint) {
463 tracing::warn!(
464 configured_endpoint = %self.settings.configured_endpoint,
465 received_endpoint = %headers.endpoint,
466 "serverless start rejected: endpoint mismatch",
467 );
468 return Err(EndpointMismatch {
469 expected: self.settings.configured_endpoint.clone(),
470 received: headers.endpoint.clone(),
471 }
472 .build());
473 }
474
475 if headers.namespace != self.settings.configured_namespace {
476 tracing::warn!(
477 configured_namespace = %self.settings.configured_namespace,
478 received_namespace = %headers.namespace,
479 "serverless start rejected: namespace mismatch",
480 );
481 return Err(NamespaceMismatch {
482 expected: self.settings.configured_namespace.clone(),
483 received: headers.namespace.clone(),
484 }
485 .build());
486 }
487 }
488
489 Ok(())
490 }
491
492 async fn ensure_envoy(&self, headers: &StartHeaders) -> Result<EnvoyHandle> {
493 if self.shutting_down.load(Ordering::Acquire) {
494 return Err(RuntimeShutDown.build());
495 }
496 if !self.settings.cache_envoy {
497 return self.start_envoy(headers).await;
498 }
499 let mut guard = self.envoy.lock().await;
500 if let Some(handle) = guard.as_ref() {
501 if !endpoints_match(handle.endpoint(), &headers.endpoint)
504 || handle.namespace() != headers.namespace
505 || handle.pool_name() != headers.pool_name
506 {
507 anyhow::bail!("serverless start headers do not match active envoy");
508 }
509 return Ok(handle.clone());
510 }
511
512 let handle = self.start_envoy(headers).await?;
513 if self.shutting_down.load(Ordering::Acquire) {
517 drop(guard);
518 match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)).await {
519 Ok(()) => {}
520 Err(_) => {
521 handle.shutdown(true);
522 handle.wait_stopped().await;
523 }
524 }
525 return Err(RuntimeShutDown.build());
526 }
527 *guard = Some(handle.clone());
528 Ok(handle)
529 }
530
531 async fn start_envoy(&self, headers: &StartHeaders) -> Result<EnvoyHandle> {
532 let callbacks = Arc::new(RegistryCallbacks {
533 dispatcher: self.dispatcher.clone(),
534 });
535 let prepopulate_actor_names = self
536 .dispatcher
537 .build_actor_metadata_map()
538 .into_iter()
539 .map(|(name, metadata)| (name, EnvoyActorName { metadata }))
540 .collect();
541 Ok(start_envoy_client(EnvoyConfig {
546 version: self.settings.version,
547 endpoint: headers.endpoint.clone(),
548 token: headers.token.clone(),
549 namespace: headers.namespace.clone(),
550 pool_name: headers.pool_name.clone(),
551 prepopulate_actor_names,
552 metadata: Some(json!({
553 "rivetkit": { "version": self.settings.package_version },
554 })),
555 not_global: true,
556 debug_latency_ms: None,
557 callbacks,
558 })
559 .await)
560 }
561}
562
563fn route_path(base_path: &str, url: &str) -> Result<String> {
564 let parsed = Url::parse(url).with_context(|| format!("parse request URL `{url}`"))?;
565 let path = parsed.path();
566 if path == base_path {
567 return Ok(String::new());
568 }
569 let prefix = format!("{base_path}/");
570 if let Some(rest) = path.strip_prefix(&prefix) {
571 return Ok(format!("/{rest}"));
572 }
573 Ok(path.to_owned())
574}
575
576fn parse_start_headers(headers: &HashMap<String, String>) -> Result<StartHeaders> {
577 let pool_name = match optional_header(headers, "x-rivet-pool-name") {
578 Some(pool_name) => pool_name,
579 None => optional_header(headers, "x-rivet-runner-name").ok_or_else(|| {
580 InvalidRequest {
581 reason: "x-rivet-pool-name header is required".to_string(),
582 }
583 .build()
584 })?,
585 };
586
587 Ok(StartHeaders {
588 endpoint: required_header(headers, "x-rivet-endpoint")?,
589 token: optional_header(headers, "x-rivet-token"),
590 pool_name,
591 namespace: required_header(headers, "x-rivet-namespace-name")?,
592 })
593}
594
595fn required_header(headers: &HashMap<String, String>, name: &str) -> Result<String> {
596 headers
597 .get(name)
598 .filter(|value| !value.is_empty())
599 .cloned()
600 .ok_or_else(|| {
601 InvalidRequest {
602 reason: format!("{name} header is required"),
603 }
604 .build()
605 })
606}
607
608fn optional_header(headers: &HashMap<String, String>, name: &str) -> Option<String> {
609 headers.get(name).filter(|value| !value.is_empty()).cloned()
610}
611
612fn cors_headers(req: &ServerlessRequest) -> HashMap<String, String> {
613 let origin = req
614 .headers
615 .get("origin")
616 .cloned()
617 .unwrap_or_else(|| "*".to_owned());
618 let mut headers = HashMap::from([
619 ("access-control-allow-origin".to_owned(), origin.clone()),
620 (
621 "access-control-allow-credentials".to_owned(),
622 "true".to_owned(),
623 ),
624 ("access-control-expose-headers".to_owned(), "*".to_owned()),
625 ]);
626 if origin != "*" {
627 headers.insert("vary".to_owned(), "Origin".to_owned());
628 }
629
630 if req.method == "OPTIONS" {
631 headers.insert(
632 "access-control-allow-methods".to_owned(),
633 "GET, POST, PUT, DELETE, OPTIONS, PATCH".to_owned(),
634 );
635 headers.insert(
636 "access-control-allow-headers".to_owned(),
637 req.headers
638 .get("access-control-request-headers")
639 .cloned()
640 .unwrap_or_else(|| "*".to_owned()),
641 );
642 headers.insert("access-control-max-age".to_owned(), "86400".to_owned());
643 }
644
645 headers
646}
647
648fn apply_cors(headers: &mut HashMap<String, String>, cors: HashMap<String, String>) {
649 headers.extend(cors);
650}
651
652fn normalize_base_path(base_path: Option<&str>) -> String {
653 let base_path = base_path
654 .filter(|base_path| !base_path.is_empty())
655 .unwrap_or(DEFAULT_BASE_PATH);
656 let prefixed = if base_path.starts_with('/') {
657 base_path.to_owned()
658 } else {
659 format!("/{base_path}")
660 };
661 let trimmed = prefixed.trim_end_matches('/');
662 if trimmed.is_empty() {
663 "/".to_owned()
664 } else {
665 trimmed.to_owned()
666 }
667}
668
669fn text_response(status: StatusCode, content_type: &str, body: &str) -> ServerlessResponse {
670 bytes_response(
671 status,
672 HashMap::from([("content-type".to_owned(), content_type.to_owned())]),
673 body.as_bytes().to_vec(),
674 )
675}
676
677fn json_response(status: StatusCode, body: serde_json::Value) -> ServerlessResponse {
678 bytes_response(
679 status,
680 HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
681 serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
682 )
683}
684
685fn metrics_response(headers: &HashMap<String, String>) -> ServerlessResponse {
686 let bearer_token = crate::metrics_endpoint::authorization_bearer_token_map(headers);
687 match crate::metrics_endpoint::authorize_metrics_request(bearer_token) {
688 Ok(()) => match crate::metrics_endpoint::render_prometheus_metrics() {
689 Ok(metrics) => bytes_response(
690 StatusCode::OK,
691 HashMap::from([("content-type".to_owned(), metrics.content_type)]),
692 metrics.body,
693 ),
694 Err(error) => error_response(error),
695 },
696 Err(crate::metrics_endpoint::MetricsAccessError::NotEnabled) => text_response(
697 StatusCode::FORBIDDEN,
698 "text/plain; charset=utf-8",
699 "metrics not enabled\n",
700 ),
701 Err(crate::metrics_endpoint::MetricsAccessError::Unauthorized) => text_response(
702 StatusCode::UNAUTHORIZED,
703 "text/plain; charset=utf-8",
704 "metrics request requires a valid bearer token\n",
705 ),
706 }
707}
708
709fn bytes_response(
710 status: StatusCode,
711 headers: HashMap<String, String>,
712 body: Vec<u8>,
713) -> ServerlessResponse {
714 let (tx, rx) = mpsc::unbounded_channel();
715 let _ = tx.send(Ok(body));
716 ServerlessResponse {
717 status: status.as_u16(),
718 headers,
719 body: rx,
720 }
721}
722
723fn error_response(error: anyhow::Error) -> ServerlessResponse {
724 let extracted = rivet_error::RivetError::extract(&error);
725 let status = serverless_error_status(extracted.group(), extracted.code());
726 let body = ServerlessErrorBody {
727 group: extracted.group(),
728 code: extracted.code(),
729 message: extracted.message().to_owned(),
730 metadata: extracted.metadata().unwrap_or(serde_json::Value::Null),
731 };
732 bytes_response(
733 status,
734 HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
735 serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
736 )
737}
738
739fn serverless_error_status(group: &str, code: &str) -> StatusCode {
740 match (group, code) {
741 ("auth", "forbidden") => StatusCode::FORBIDDEN,
742 ("message", "incoming_too_long") => StatusCode::PAYLOAD_TOO_LARGE,
743 _ => StatusCode::BAD_REQUEST,
744 }
745}
746
747fn stream_error(error: anyhow::Error) -> ServerlessStreamError {
748 let extracted = rivet_error::RivetError::extract(&error);
749 ServerlessStreamError {
750 group: extracted.group().to_owned(),
751 code: extracted.code().to_owned(),
752 message: extracted.message().to_owned(),
753 }
754}
755
756pub fn normalize_endpoint_url(url: &str) -> Option<String> {
757 let parsed = Url::parse(url).ok()?;
758 let pathname = if parsed.path() == "/" {
759 "/".to_owned()
760 } else {
761 parsed.path().trim_end_matches('/').to_owned()
762 };
763 let mut hostname = parsed.host_str()?.to_owned();
764 if is_loopback_address(&hostname) {
765 hostname = "localhost".to_owned();
766 }
767 hostname = normalize_regional_hostname(&hostname);
768 let host = match parsed.port() {
769 Some(port) => format!("{hostname}:{port}"),
770 None => hostname,
771 };
772 Some(format!("{}://{}{}", parsed.scheme(), host, pathname))
773}
774
775fn normalized_endpoint_candidates(value: &str) -> Vec<String> {
776 value
777 .split(',')
778 .map(str::trim)
779 .filter(|candidate| !candidate.is_empty())
780 .map(|candidate| normalize_endpoint_url(candidate).unwrap_or_else(|| candidate.to_owned()))
781 .collect()
782}
783
784pub fn endpoints_match(a: &str, b: &str) -> bool {
785 let a_candidates = normalized_endpoint_candidates(a);
786 let b_candidates = normalized_endpoint_candidates(b);
787 a_candidates.iter().any(|a_candidate| {
788 b_candidates
789 .iter()
790 .any(|b_candidate| a_candidate == b_candidate)
791 })
792}
793
794fn normalize_regional_hostname(hostname: &str) -> String {
795 if !hostname.ends_with(".rivet.dev") || !hostname.starts_with("api-") {
796 return hostname.to_owned();
797 }
798 let without_prefix = &hostname[4..];
799 let Some(first_dot_index) = without_prefix.find('.') else {
800 return hostname.to_owned();
801 };
802 let domain = &without_prefix[first_dot_index + 1..];
803 format!("api.{domain}")
804}
805
806fn is_loopback_address(hostname: &str) -> bool {
807 matches!(hostname, "127.0.0.1" | "0.0.0.0" | "::1" | "[::1]")
808}
809
810#[cfg(test)]
812#[path = "../tests/serverless.rs"]
813mod tests;