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