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	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	/// Listener-side body cap; reuses the `/start` payload limit.
255	pub fn max_request_body_bytes(&self) -> usize {
256		self.settings.max_start_payload_bytes
257	}
258
259	/// Canonical 413 response built through the `RivetError` system.
260	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	/// Canonical 400 response for malformed requests.
269	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				// Healthy if no envoy is connected yet or if the envoy has received a
302				// recent engine ping. Unhealthy only when an envoy exists but has not
303				// received a recent ping. 503 is the conventional "recycle me" signal
304				// for container hosts running behind an HTTP health probe.
305				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			// The start request token authenticates the serverless callback. It is not part
502			// of envoy identity, and may differ from the token used for the engine connection.
503			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		// Re-check under the lock: shutdown may have run while we were awaiting
514		// `start_envoy`. If so, tear down the freshly-built envoy rather than
515		// installing it into the cache.
516		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		// not_global: true to avoid caching the handle in the process-wide
542		// `GLOBAL_ENVOY` OnceLock. Without this, a shutdown-during-build race
543		// (spec ยง3 step 7) leaves a dead handle cached for the life of the
544		// process and any subsequent consumer gets it back.
545		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// Test shim keeps moved tests in crate-root tests/ with private-module access.
811#[cfg(test)]
812#[path = "../tests/serverless.rs"]
813mod tests;