Skip to main content

rivetkit_core/
serverless.rs

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::development_process::DevelopmentProcessManager;
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";
35/// Bound on `handle.shutdown_and_wait` inside teardown paths. If envoy cannot
36/// reach the engine (reconnect loop stuck), we fall back to immediate `Stop`
37/// rather than hanging indefinitely. Must stay below the outer TS grace ceiling.
38const 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	development_processes: Arc<TokioMutex<Option<DevelopmentProcessManager>>>,
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		let manage_engine = should_manage_engine(&config.endpoint, config.engine_spawn)?;
160		#[cfg(feature = "native-runtime")]
161		let development_processes = if manage_engine {
162			Some(DevelopmentProcessManager::start(&config).await?)
163		} else {
164			None
165		};
166		#[cfg(not(feature = "native-runtime"))]
167		if manage_engine {
168			anyhow::bail!("engine process spawning requires the `native-runtime` feature");
169		}
170
171		let dispatcher = Arc::new(RegistryDispatcher::new(
172			factories,
173			config.handle_inspector_http_in_runtime,
174		));
175		let base_path = normalize_base_path(config.serverless_base_path.as_deref());
176		crate::metrics::record_rivetkit_info(
177			config.serverless_package_version.clone(),
178			config.version,
179			"serverless",
180			config.pool_name.clone(),
181		);
182		#[cfg(not(target_arch = "wasm32"))]
183		crate::tokio_runtime_metrics::ensure_sampler_started();
184
185		Ok(Self {
186			settings: Arc::new(ServerlessSettings {
187				version: config.version,
188				configured_endpoint: config.endpoint,
189				configured_namespace: config.namespace,
190				base_path,
191				package_version: config.serverless_package_version,
192				client_endpoint: config.serverless_client_endpoint,
193				client_namespace: config.serverless_client_namespace,
194				client_token: config.serverless_client_token,
195				validate_endpoint: config.serverless_validate_endpoint,
196				max_start_payload_bytes: config.serverless_max_start_payload_bytes,
197				cache_envoy: config.serverless_cache_envoy,
198			}),
199			dispatcher,
200			envoy: Arc::new(TokioMutex::new(None)),
201			#[cfg(feature = "native-runtime")]
202			development_processes: Arc::new(TokioMutex::new(development_processes)),
203			shutting_down: Arc::new(AtomicBool::new(false)),
204		})
205	}
206
207	/// Tear down the cached envoy handle. Idempotent.
208	///
209	/// Sets `shutting_down` so concurrent `ensure_envoy` callers short-circuit
210	/// instead of starting a fresh envoy after teardown, and waits (with a
211	/// bounded timeout) for `envoy_loop` to exit. If the drain exceeds the
212	/// timeout (e.g. engine unreachable), falls back to an immediate `Stop`.
213	pub async fn shutdown(&self) {
214		self.shutting_down.store(true, Ordering::Release);
215		let handle = { self.envoy.lock().await.take() };
216		#[cfg(feature = "native-runtime")]
217		let development_processes = { self.development_processes.lock().await.take() };
218		let shutdown_envoy = async move {
219			if let Some(handle) = handle {
220				match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)).await {
221					Ok(()) => {}
222					Err(_) => {
223						tracing::warn!(
224							"serverless runtime envoy drain exceeded timeout; forcing immediate stop"
225						);
226						handle.shutdown(true);
227						handle.wait_stopped().await;
228					}
229				}
230			}
231		};
232		#[cfg(feature = "native-runtime")]
233		let shutdown_development_processes = async move {
234			if let Some(development_processes) = development_processes {
235				development_processes.shutdown().await;
236			}
237		};
238		#[cfg(feature = "native-runtime")]
239		tokio::join!(shutdown_envoy, shutdown_development_processes);
240		#[cfg(not(feature = "native-runtime"))]
241		shutdown_envoy.await;
242	}
243
244	/// Wait (bounded) for active actors to reach zero, so an in-flight actor's `Stopped`
245	/// reaches the engine before `shutdown` announces the envoy is going away. Otherwise
246	/// that announcement becomes a `GoingAway` that overrides the destroy and reallocates.
247	pub async fn wait_actors_drained(&self, timeout_dur: Duration) {
248		let handle = { self.envoy.lock().await.as_ref().cloned() };
249		let Some(handle) = handle else { return };
250		let _ = timeout(
251			timeout_dur,
252			CoreEnvoyHandle::new(handle).wait_actors_drained(),
253		)
254		.await;
255	}
256
257	pub async fn active_envoy_actor_count(&self) -> Option<usize> {
258		self.active_envoy_status()
259			.await
260			.map(|status| status.active_actor_count)
261	}
262
263	pub async fn active_envoy_status(&self) -> Option<CoreEnvoyStatus> {
264		self.envoy
265			.lock()
266			.await
267			.as_ref()
268			.map(|handle| CoreEnvoyHandle::new(handle.clone()).status())
269	}
270
271	pub async fn active_envoy_actor_stop_threshold_ms(&self) -> Option<i64> {
272		let handle = self.envoy.lock().await.as_ref().cloned()?;
273		CoreEnvoyHandle::new(handle).actor_stop_threshold_ms().await
274	}
275
276	/// Listener-side body cap; reuses the `/start` payload limit.
277	pub fn max_request_body_bytes(&self) -> usize {
278		self.settings.max_start_payload_bytes
279	}
280
281	/// Returns whether the native listener should reserve this URL for
282	/// RivetKit's framework routes instead of forwarding it to an application
283	/// fallback.
284	pub fn handles_listener_request(&self, url: &str) -> bool {
285		handles_listener_request(&self.settings.base_path, url)
286	}
287
288	/// Canonical 413 response built through the `RivetError` system.
289	pub fn incoming_too_long_response(&self) -> ServerlessResponse {
290		let error = IncomingMessageTooLong {
291			limit: self.settings.max_start_payload_bytes,
292		}
293		.build();
294		error_response(error)
295	}
296
297	/// Canonical 400 response for malformed requests.
298	pub fn invalid_request_response(&self, reason: impl Into<String>) -> ServerlessResponse {
299		let error = InvalidRequest {
300			reason: reason.into(),
301		}
302		.build();
303		error_response(error)
304	}
305
306	pub async fn handle_request(&self, req: ServerlessRequest) -> ServerlessResponse {
307		let cors = cors_headers(&req);
308		match self.handle_request_inner(req).await {
309			Ok(mut response) => {
310				apply_cors(&mut response.headers, cors);
311				response
312			}
313			Err(error) => {
314				let mut response = error_response(error);
315				apply_cors(&mut response.headers, cors);
316				response
317			}
318		}
319	}
320
321	async fn handle_request_inner(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
322		let path = route_path(&self.settings.base_path, &req.url)?;
323		match (req.method.as_str(), path.as_str()) {
324			("GET", "") | ("GET", "/") => Ok(text_response(
325				StatusCode::OK,
326				"text/plain; charset=utf-8",
327				"This is a RivetKit server.\n\nLearn more at https://rivet.dev",
328			)),
329			("GET", "/health") => {
330				// Healthy if no envoy is connected yet or if the envoy has received a
331				// recent engine ping. Unhealthy only when an envoy exists but has not
332				// received a recent ping. 503 is the conventional "recycle me" signal
333				// for container hosts running behind an HTTP health probe.
334				let runtime_healthy = {
335					let guard = self.envoy.lock().await;
336					guard
337						.as_ref()
338						.map(|handle| handle.is_ping_healthy())
339						.unwrap_or(true)
340				};
341				if runtime_healthy {
342					Ok(json_response(
343						StatusCode::OK,
344						json!({
345							"status": "ok",
346							"runtime": "rivetkit",
347							"version": self.settings.package_version,
348						}),
349					))
350				} else {
351					Ok(json_response(
352						StatusCode::SERVICE_UNAVAILABLE,
353						json!({
354							"status": "engine_ping_stale",
355							"runtime": "rivetkit",
356							"version": self.settings.package_version,
357						}),
358					))
359				}
360			}
361			("GET", "/metadata") => Ok(self.metadata_response()),
362			("GET", "/start") | ("POST", "/start") => self.start_response(req).await,
363			("OPTIONS", _) => Ok(bytes_response(
364				StatusCode::NO_CONTENT,
365				HashMap::new(),
366				Vec::new(),
367			)),
368			_ => Ok(text_response(
369				StatusCode::NOT_FOUND,
370				"text/plain; charset=utf-8",
371				"Not Found (RivetKit)",
372			)),
373		}
374	}
375
376	async fn start_response(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
377		let headers = parse_start_headers(&req.headers)?;
378		self.validate_start_headers(&headers)?;
379		crate::metrics::record_rivetkit_info(
380			self.settings.package_version.clone(),
381			self.settings.version,
382			"serverless",
383			headers.pool_name.clone(),
384		);
385		#[cfg(not(target_arch = "wasm32"))]
386		crate::tokio_runtime_metrics::ensure_sampler_started();
387		if req.body.len() > self.settings.max_start_payload_bytes {
388			return Err(IncomingMessageTooLong {
389				limit: self.settings.max_start_payload_bytes,
390			}
391			.build());
392		}
393
394		let handle = self.ensure_envoy(&headers).await?;
395		let payload = req.body;
396		let actor_start = handle.decode_serverless_actor_start(&payload)?;
397		let cancel_token = req.cancel_token;
398		let cache_envoy = self.settings.cache_envoy;
399		let (tx, rx) = mpsc::unbounded_channel();
400		let _ = tx.send(Ok(SSE_PING_FRAME.to_vec()));
401
402		RuntimeSpawner::spawn(async move {
403			let shutdown_handle = handle.clone();
404			let result = tokio::select! {
405				_ = cancel_token.cancelled() => {
406					if !cache_envoy {
407						shutdown_handle.shutdown_and_wait(false).await;
408					}
409					return;
410				}
411				result = handle.start_serverless_actor(&payload) => result,
412			};
413			if let Err(error) = result {
414				let error = stream_error(error);
415				let _ = tx.send(Err(error));
416				if !cache_envoy {
417					handle.shutdown_and_wait(false).await;
418				}
419				return;
420			}
421
422			loop {
423				tokio::select! {
424					_ = cancel_token.cancelled() => {
425						break;
426					}
427					_ = handle.wait_actor_registered_then_stopped(&actor_start.actor_id, actor_start.generation) => {
428						let _ = tx.send(Ok(SSE_STOPPING_FRAME.to_vec()));
429						break;
430					}
431					_ = sleep(SSE_PING_INTERVAL) => {
432						if tx.send(Ok(SSE_PING_FRAME.to_vec())).is_err() {
433							break;
434						}
435					}
436				}
437			}
438
439			if !cache_envoy {
440				handle.shutdown_and_wait(false).await;
441			}
442		});
443
444		Ok(ServerlessResponse {
445			status: StatusCode::OK.as_u16(),
446			headers: HashMap::from([
447				("content-type".to_owned(), "text/event-stream".to_owned()),
448				("cache-control".to_owned(), "no-cache".to_owned()),
449				("connection".to_owned(), "keep-alive".to_owned()),
450			]),
451			body: rx,
452		})
453	}
454
455	fn metadata_response(&self) -> ServerlessResponse {
456		let actor_names = self
457			.dispatcher
458			.build_actor_metadata_map()
459			.into_iter()
460			.map(|(name, metadata)| {
461				(
462					name,
463					ActorName {
464						metadata: Some(metadata),
465					},
466				)
467			})
468			.collect::<HashMap<_, _>>();
469
470		let payload = ServerlessMetadataPayload {
471			runtime: "rivetkit".to_owned(),
472			version: self.settings.package_version.clone(),
473			envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
474			actor_names,
475			envoy: Some(ServerlessMetadataEnvoy {
476				kind: Some(ServerlessMetadataEnvoyKind::Serverless {}),
477				version: Some(self.settings.version),
478			}),
479			runner: None,
480			client_endpoint: self.settings.client_endpoint.clone(),
481			client_namespace: self.settings.client_namespace.clone(),
482			client_token: self.settings.client_token.clone(),
483		};
484
485		let response = serde_json::to_value(payload).unwrap_or_else(|_| json!({}));
486
487		json_response(StatusCode::OK, response)
488	}
489
490	fn validate_start_headers(&self, headers: &StartHeaders) -> Result<()> {
491		if self.settings.validate_endpoint {
492			if !endpoints_match(&headers.endpoint, &self.settings.configured_endpoint) {
493				tracing::warn!(
494					configured_endpoint = %self.settings.configured_endpoint,
495					received_endpoint = %headers.endpoint,
496					"serverless start rejected: endpoint mismatch",
497				);
498				return Err(EndpointMismatch {
499					expected: self.settings.configured_endpoint.clone(),
500					received: headers.endpoint.clone(),
501				}
502				.build());
503			}
504
505			if headers.namespace != self.settings.configured_namespace {
506				tracing::warn!(
507					configured_namespace = %self.settings.configured_namespace,
508					received_namespace = %headers.namespace,
509					"serverless start rejected: namespace mismatch",
510				);
511				return Err(NamespaceMismatch {
512					expected: self.settings.configured_namespace.clone(),
513					received: headers.namespace.clone(),
514				}
515				.build());
516			}
517		}
518
519		Ok(())
520	}
521
522	async fn ensure_envoy(&self, headers: &StartHeaders) -> Result<EnvoyHandle> {
523		if self.shutting_down.load(Ordering::Acquire) {
524			return Err(RuntimeShutDown.build());
525		}
526		if !self.settings.cache_envoy {
527			return self.start_envoy(headers).await;
528		}
529		let mut guard = self.envoy.lock().await;
530		if let Some(handle) = guard.as_ref() {
531			// The start request token authenticates the serverless callback. It is not part
532			// of envoy identity, and may differ from the token used for the engine connection.
533			if !endpoints_match(handle.endpoint(), &headers.endpoint)
534				|| handle.namespace() != headers.namespace
535				|| handle.pool_name() != headers.pool_name
536			{
537				anyhow::bail!("serverless start headers do not match active envoy");
538			}
539			return Ok(handle.clone());
540		}
541
542		let handle = self.start_envoy(headers).await?;
543		// Re-check under the lock: shutdown may have run while we were awaiting
544		// `start_envoy`. If so, tear down the freshly-built envoy rather than
545		// installing it into the cache.
546		if self.shutting_down.load(Ordering::Acquire) {
547			drop(guard);
548			match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)).await {
549				Ok(()) => {}
550				Err(_) => {
551					handle.shutdown(true);
552					handle.wait_stopped().await;
553				}
554			}
555			return Err(RuntimeShutDown.build());
556		}
557		*guard = Some(handle.clone());
558		Ok(handle)
559	}
560
561	async fn start_envoy(&self, headers: &StartHeaders) -> Result<EnvoyHandle> {
562		let callbacks = Arc::new(RegistryCallbacks {
563			dispatcher: self.dispatcher.clone(),
564		});
565		let prepopulate_actor_names = self
566			.dispatcher
567			.build_actor_metadata_map()
568			.into_iter()
569			.map(|(name, metadata)| (name, EnvoyActorName { metadata }))
570			.collect();
571		// not_global: true to avoid caching the handle in the process-wide
572		// `GLOBAL_ENVOY` OnceLock. Without this, a shutdown-during-build race
573		// (spec ยง3 step 7) leaves a dead handle cached for the life of the
574		// process and any subsequent consumer gets it back.
575		Ok(start_envoy_client(EnvoyConfig {
576			version: self.settings.version,
577			endpoint: headers.endpoint.clone(),
578			token: headers.token.clone(),
579			namespace: headers.namespace.clone(),
580			pool_name: headers.pool_name.clone(),
581			prepopulate_actor_names,
582			metadata: Some(json!({
583				"rivetkit": { "version": self.settings.package_version },
584			})),
585			not_global: true,
586			debug_latency_ms: None,
587			callbacks,
588		})
589		.await)
590	}
591}
592
593fn route_path(base_path: &str, url: &str) -> Result<String> {
594	let parsed = Url::parse(url).with_context(|| format!("parse request URL `{url}`"))?;
595	let path = parsed.path();
596	if path == base_path {
597		return Ok(String::new());
598	}
599	let prefix = format!("{base_path}/");
600	if let Some(rest) = path.strip_prefix(&prefix) {
601		return Ok(format!("/{rest}"));
602	}
603	Ok(path.to_owned())
604}
605
606fn handles_listener_request(base_path: &str, url: &str) -> bool {
607	let Ok(parsed) = Url::parse(url) else {
608		// Let the normal framework handler return its structured invalid URL
609		// response rather than passing malformed input to user code.
610		return true;
611	};
612	let request_path = parsed.path();
613	if request_path != base_path && !request_path.starts_with(&format!("{base_path}/")) {
614		return false;
615	}
616	let path = route_path(base_path, url).expect("URL was parsed and the same base path is valid");
617	matches!(path.as_str(), "" | "/" | "/health" | "/metadata" | "/start")
618}
619
620fn parse_start_headers(headers: &HashMap<String, String>) -> Result<StartHeaders> {
621	let pool_name = match optional_header(headers, "x-rivet-pool-name") {
622		Some(pool_name) => pool_name,
623		None => optional_header(headers, "x-rivet-runner-name").ok_or_else(|| {
624			InvalidRequest {
625				reason: "x-rivet-pool-name header is required".to_string(),
626			}
627			.build()
628		})?,
629	};
630
631	Ok(StartHeaders {
632		endpoint: required_header(headers, "x-rivet-endpoint")?,
633		token: optional_header(headers, "x-rivet-token"),
634		pool_name,
635		namespace: required_header(headers, "x-rivet-namespace-name")?,
636	})
637}
638
639fn required_header(headers: &HashMap<String, String>, name: &str) -> Result<String> {
640	headers
641		.get(name)
642		.filter(|value| !value.is_empty())
643		.cloned()
644		.ok_or_else(|| {
645			InvalidRequest {
646				reason: format!("{name} header is required"),
647			}
648			.build()
649		})
650}
651
652fn optional_header(headers: &HashMap<String, String>, name: &str) -> Option<String> {
653	headers.get(name).filter(|value| !value.is_empty()).cloned()
654}
655
656fn cors_headers(req: &ServerlessRequest) -> HashMap<String, String> {
657	let origin = req
658		.headers
659		.get("origin")
660		.cloned()
661		.unwrap_or_else(|| "*".to_owned());
662	let mut headers = HashMap::from([
663		("access-control-allow-origin".to_owned(), origin.clone()),
664		(
665			"access-control-allow-credentials".to_owned(),
666			"true".to_owned(),
667		),
668		("access-control-expose-headers".to_owned(), "*".to_owned()),
669	]);
670	if origin != "*" {
671		headers.insert("vary".to_owned(), "Origin".to_owned());
672	}
673
674	if req.method == "OPTIONS" {
675		headers.insert(
676			"access-control-allow-methods".to_owned(),
677			"GET, POST, PUT, DELETE, OPTIONS, PATCH".to_owned(),
678		);
679		headers.insert(
680			"access-control-allow-headers".to_owned(),
681			req.headers
682				.get("access-control-request-headers")
683				.cloned()
684				.unwrap_or_else(|| "*".to_owned()),
685		);
686		headers.insert("access-control-max-age".to_owned(), "86400".to_owned());
687	}
688
689	headers
690}
691
692fn apply_cors(headers: &mut HashMap<String, String>, cors: HashMap<String, String>) {
693	headers.extend(cors);
694}
695
696fn normalize_base_path(base_path: Option<&str>) -> String {
697	let base_path = base_path
698		.filter(|base_path| !base_path.is_empty())
699		.unwrap_or(DEFAULT_BASE_PATH);
700	let prefixed = if base_path.starts_with('/') {
701		base_path.to_owned()
702	} else {
703		format!("/{base_path}")
704	};
705	let trimmed = prefixed.trim_end_matches('/');
706	if trimmed.is_empty() {
707		"/".to_owned()
708	} else {
709		trimmed.to_owned()
710	}
711}
712
713fn text_response(status: StatusCode, content_type: &str, body: &str) -> ServerlessResponse {
714	bytes_response(
715		status,
716		HashMap::from([("content-type".to_owned(), content_type.to_owned())]),
717		body.as_bytes().to_vec(),
718	)
719}
720
721fn json_response(status: StatusCode, body: serde_json::Value) -> ServerlessResponse {
722	bytes_response(
723		status,
724		HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
725		serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
726	)
727}
728
729fn bytes_response(
730	status: StatusCode,
731	headers: HashMap<String, String>,
732	body: Vec<u8>,
733) -> ServerlessResponse {
734	let (tx, rx) = mpsc::unbounded_channel();
735	let _ = tx.send(Ok(body));
736	ServerlessResponse {
737		status: status.as_u16(),
738		headers,
739		body: rx,
740	}
741}
742
743fn error_response(error: anyhow::Error) -> ServerlessResponse {
744	let extracted = rivet_error::RivetError::extract(&error);
745	let status = serverless_error_status(extracted.group(), extracted.code());
746	let body = ServerlessErrorBody {
747		group: extracted.group(),
748		code: extracted.code(),
749		message: extracted.message().to_owned(),
750		metadata: extracted.metadata().unwrap_or(serde_json::Value::Null),
751	};
752	bytes_response(
753		status,
754		HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
755		serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
756	)
757}
758
759fn serverless_error_status(group: &str, code: &str) -> StatusCode {
760	match (group, code) {
761		("auth", "forbidden") => StatusCode::FORBIDDEN,
762		("message", "incoming_too_long") => StatusCode::PAYLOAD_TOO_LARGE,
763		_ => StatusCode::BAD_REQUEST,
764	}
765}
766
767fn stream_error(error: anyhow::Error) -> ServerlessStreamError {
768	let extracted = rivet_error::RivetError::extract(&error);
769	ServerlessStreamError {
770		group: extracted.group().to_owned(),
771		code: extracted.code().to_owned(),
772		message: extracted.message().to_owned(),
773	}
774}
775
776pub fn normalize_endpoint_url(url: &str) -> Option<String> {
777	let parsed = Url::parse(url).ok()?;
778	let pathname = if parsed.path() == "/" {
779		"/".to_owned()
780	} else {
781		parsed.path().trim_end_matches('/').to_owned()
782	};
783	let mut hostname = parsed.host_str()?.to_owned();
784	if is_loopback_address(&hostname) {
785		hostname = "localhost".to_owned();
786	}
787	hostname = normalize_regional_hostname(&hostname);
788	let host = match parsed.port() {
789		Some(port) => format!("{hostname}:{port}"),
790		None => hostname,
791	};
792	Some(format!("{}://{}{}", parsed.scheme(), host, pathname))
793}
794
795fn normalized_endpoint_candidates(value: &str) -> Vec<String> {
796	value
797		.split(',')
798		.map(str::trim)
799		.filter(|candidate| !candidate.is_empty())
800		.map(|candidate| normalize_endpoint_url(candidate).unwrap_or_else(|| candidate.to_owned()))
801		.collect()
802}
803
804pub fn endpoints_match(a: &str, b: &str) -> bool {
805	let a_candidates = normalized_endpoint_candidates(a);
806	let b_candidates = normalized_endpoint_candidates(b);
807	a_candidates.iter().any(|a_candidate| {
808		b_candidates
809			.iter()
810			.any(|b_candidate| a_candidate == b_candidate)
811	})
812}
813
814fn normalize_regional_hostname(hostname: &str) -> String {
815	if !hostname.ends_with(".rivet.dev") || !hostname.starts_with("api-") {
816		return hostname.to_owned();
817	}
818	let without_prefix = &hostname[4..];
819	let Some(first_dot_index) = without_prefix.find('.') else {
820		return hostname.to_owned();
821	};
822	let domain = &without_prefix[first_dot_index + 1..];
823	format!("api.{domain}")
824}
825
826fn is_loopback_address(hostname: &str) -> bool {
827	matches!(hostname, "127.0.0.1" | "0.0.0.0" | "::1" | "[::1]")
828}
829
830// Test shim keeps moved tests in crate-root tests/ with private-module access.
831#[cfg(test)]
832#[path = "../tests/serverless.rs"]
833mod tests;