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