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_sync;
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::{Instant, 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/// Bound on waiting for a new envoy to receive the engine's init message. A
40/// start that misses it is torn down so the next `/start` retries from scratch.
41const ENVOY_START_TIMEOUT: Duration = Duration::from_secs(30);
42
43#[derive(Clone)]
44pub struct CoreServerlessRuntime {
45	settings: Arc<ServerlessSettings>,
46	dispatcher: Arc<RegistryDispatcher>,
47	envoy: Arc<TokioMutex<Option<CachedEnvoy>>>,
48	#[cfg(feature = "native-runtime")]
49	development_processes: Arc<TokioMutex<Option<DevelopmentProcessManager>>>,
50	shutting_down: Arc<AtomicBool>,
51}
52
53/// A cached envoy and the deadline for its first engine init message.
54///
55/// Every `/start` request that shares the envoy waits against the same
56/// deadline, so an earlier request cannot time out and stop an envoy that a
57/// later request is still waiting on with time left.
58#[derive(Clone)]
59struct CachedEnvoy {
60	handle: EnvoyHandle,
61	start_deadline: Instant,
62}
63
64#[derive(Clone, Debug)]
65struct ServerlessSettings {
66	version: u32,
67	configured_endpoint: String,
68	configured_namespace: String,
69	base_path: String,
70	package_version: String,
71	client_endpoint: Option<String>,
72	client_namespace: Option<String>,
73	client_token: Option<String>,
74	validate_endpoint: bool,
75	max_start_payload_bytes: usize,
76	cache_envoy: bool,
77}
78
79#[derive(Debug)]
80pub struct ServerlessRequest {
81	pub method: String,
82	pub url: String,
83	pub headers: HashMap<String, String>,
84	pub body: Vec<u8>,
85	pub cancel_token: CancellationToken,
86}
87
88#[derive(Debug)]
89pub struct ServerlessResponse {
90	pub status: u16,
91	pub headers: HashMap<String, String>,
92	pub body: mpsc::UnboundedReceiver<Result<Vec<u8>, ServerlessStreamError>>,
93}
94
95#[derive(Clone, Debug, Serialize)]
96pub struct ServerlessStreamError {
97	pub group: String,
98	pub code: String,
99	pub message: String,
100}
101
102#[derive(Debug)]
103struct StartHeaders {
104	endpoint: String,
105	token: Option<String>,
106	pool_name: String,
107	namespace: String,
108}
109
110#[derive(Debug, Serialize)]
111struct ServerlessErrorBody<'a> {
112	group: &'a str,
113	code: &'a str,
114	message: String,
115	metadata: serde_json::Value,
116}
117
118#[derive(rivet_error::RivetError, Serialize)]
119#[error("request", "invalid", "Invalid request.", "Invalid request: {reason}")]
120struct InvalidRequest {
121	reason: String,
122}
123
124#[derive(rivet_error::RivetError, Serialize)]
125#[error(
126	"config",
127	"endpoint_mismatch",
128	"Endpoint mismatch.",
129	"Endpoint mismatch: expected \"{expected}\", received \"{received}\""
130)]
131struct EndpointMismatch {
132	expected: String,
133	received: String,
134}
135
136#[derive(rivet_error::RivetError, Serialize)]
137#[error(
138	"config",
139	"namespace_mismatch",
140	"Namespace mismatch.",
141	"Namespace mismatch: expected \"{expected}\", received \"{received}\""
142)]
143struct NamespaceMismatch {
144	expected: String,
145	received: String,
146}
147
148#[derive(rivet_error::RivetError, Serialize)]
149#[error(
150	"message",
151	"incoming_too_long",
152	"Incoming message too long.",
153	"Incoming message too long. Exceeded limit of {limit} bytes."
154)]
155struct IncomingMessageTooLong {
156	limit: usize,
157}
158
159#[derive(rivet_error::RivetError, Serialize)]
160#[error(
161	"registry",
162	"shut_down",
163	"Registry is shut down.",
164	"Registry is shut down; no new requests can be accepted."
165)]
166struct RuntimeShutDown;
167
168#[derive(rivet_error::RivetError, Serialize)]
169#[error(
170	"registry",
171	"envoy_start_failed",
172	"Envoy failed to connect to the engine.",
173	"Envoy failed to connect to the engine: {reason}"
174)]
175struct EnvoyStartFailed {
176	reason: String,
177}
178
179impl CoreServerlessRuntime {
180	pub(crate) async fn new(
181		factories: HashMap<String, Arc<ActorFactory>>,
182		config: ServeConfig,
183	) -> Result<Self> {
184		#[cfg(feature = "sqlite-local")]
185		sqlite_embedded::vfs::verify_batch_atomic_writes_at_process_startup().await?;
186
187		let manage_engine = should_manage_engine(config.engine_spawn);
188		#[cfg(feature = "native-runtime")]
189		let development_processes = if manage_engine {
190			Some(DevelopmentProcessManager::start(&config).await?)
191		} else {
192			None
193		};
194		#[cfg(not(feature = "native-runtime"))]
195		if manage_engine {
196			anyhow::bail!("engine process spawning requires the `native-runtime` feature");
197		}
198
199		let dispatcher = Arc::new(RegistryDispatcher::new(
200			factories,
201			config.handle_inspector_http_in_runtime,
202		));
203		let base_path = normalize_base_path(config.serverless_base_path.as_deref());
204		crate::metrics::record_rivetkit_info(
205			config.serverless_package_version.clone(),
206			config.version,
207			"serverless",
208			config.pool_name.clone(),
209		);
210		#[cfg(not(target_arch = "wasm32"))]
211		crate::tokio_runtime_metrics::ensure_sampler_started();
212
213		Ok(Self {
214			settings: Arc::new(ServerlessSettings {
215				version: config.version,
216				configured_endpoint: config.endpoint,
217				configured_namespace: config.namespace,
218				base_path,
219				package_version: config.serverless_package_version,
220				client_endpoint: config.serverless_client_endpoint,
221				client_namespace: config.serverless_client_namespace,
222				client_token: config.serverless_client_token,
223				validate_endpoint: config.serverless_validate_endpoint,
224				max_start_payload_bytes: config.serverless_max_start_payload_bytes,
225				cache_envoy: config.serverless_cache_envoy,
226			}),
227			dispatcher,
228			envoy: Arc::new(TokioMutex::new(None)),
229			#[cfg(feature = "native-runtime")]
230			development_processes: Arc::new(TokioMutex::new(development_processes)),
231			shutting_down: Arc::new(AtomicBool::new(false)),
232		})
233	}
234
235	/// Tear down the cached envoy handle. Idempotent.
236	///
237	/// Sets `shutting_down` so concurrent `ensure_envoy` callers short-circuit
238	/// instead of starting a fresh envoy after teardown, and waits (with a
239	/// bounded timeout) for `envoy_loop` to exit. If the drain exceeds the
240	/// timeout (e.g. engine unreachable), falls back to an immediate `Stop`.
241	pub async fn shutdown(&self) {
242		self.shutting_down.store(true, Ordering::Release);
243		let handle = { self.envoy.lock().await.take() }.map(|cached| cached.handle);
244		#[cfg(feature = "native-runtime")]
245		let development_processes = { self.development_processes.lock().await.take() };
246		let shutdown_envoy = async move {
247			if let Some(handle) = handle {
248				match timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.shutdown_and_wait(false)).await {
249					Ok(()) => {}
250					Err(_) => {
251						tracing::warn!(
252							"serverless runtime envoy drain exceeded timeout; forcing immediate stop"
253						);
254						handle.shutdown(true);
255						handle.wait_stopped().await;
256					}
257				}
258			}
259		};
260		#[cfg(feature = "native-runtime")]
261		let shutdown_development_processes = async move {
262			if let Some(development_processes) = development_processes {
263				development_processes.shutdown().await;
264			}
265		};
266		#[cfg(feature = "native-runtime")]
267		tokio::join!(shutdown_envoy, shutdown_development_processes);
268		#[cfg(not(feature = "native-runtime"))]
269		shutdown_envoy.await;
270	}
271
272	/// Wait (bounded) for active actors to reach zero, so an in-flight actor's `Stopped`
273	/// reaches the engine before `shutdown` announces the envoy is going away. Otherwise
274	/// that announcement becomes a `GoingAway` that overrides the destroy and reallocates.
275	pub async fn wait_actors_drained(&self, timeout_dur: Duration) {
276		let handle = { self.cached_envoy_handle().await };
277		let Some(handle) = handle else { return };
278		let _ = timeout(
279			timeout_dur,
280			CoreEnvoyHandle::new(handle).wait_actors_drained(),
281		)
282		.await;
283	}
284
285	pub async fn active_envoy_actor_count(&self) -> Option<usize> {
286		self.active_envoy_status()
287			.await
288			.map(|status| status.active_actor_count)
289	}
290
291	pub async fn active_envoy_status(&self) -> Option<CoreEnvoyStatus> {
292		self.cached_envoy_handle()
293			.await
294			.map(|handle| CoreEnvoyHandle::new(handle).status())
295	}
296
297	pub async fn active_envoy_actor_stop_threshold_ms(&self) -> Option<i64> {
298		let handle = self.cached_envoy_handle().await?;
299		CoreEnvoyHandle::new(handle).actor_stop_threshold_ms().await
300	}
301
302	async fn cached_envoy_handle(&self) -> Option<EnvoyHandle> {
303		self.envoy
304			.lock()
305			.await
306			.as_ref()
307			.map(|cached| cached.handle.clone())
308	}
309
310	/// Listener-side body cap; reuses the `/start` payload limit.
311	pub fn max_request_body_bytes(&self) -> usize {
312		self.settings.max_start_payload_bytes
313	}
314
315	/// Returns whether the native listener should reserve this URL for
316	/// RivetKit's framework routes instead of forwarding it to an application
317	/// fallback.
318	pub fn handles_listener_request(&self, url: &str) -> bool {
319		handles_listener_request(&self.settings.base_path, url)
320	}
321
322	/// Canonical 413 response built through the `RivetError` system.
323	pub fn incoming_too_long_response(&self) -> ServerlessResponse {
324		let error = IncomingMessageTooLong {
325			limit: self.settings.max_start_payload_bytes,
326		}
327		.build();
328		error_response(error)
329	}
330
331	/// Canonical 400 response for malformed requests.
332	pub fn invalid_request_response(&self, reason: impl Into<String>) -> ServerlessResponse {
333		let error = InvalidRequest {
334			reason: reason.into(),
335		}
336		.build();
337		error_response(error)
338	}
339
340	pub async fn handle_request(&self, req: ServerlessRequest) -> ServerlessResponse {
341		let cors = cors_headers(&req);
342		match self.handle_request_inner(req).await {
343			Ok(mut response) => {
344				apply_cors(&mut response.headers, cors);
345				response
346			}
347			Err(error) => {
348				let mut response = error_response(error);
349				apply_cors(&mut response.headers, cors);
350				response
351			}
352		}
353	}
354
355	async fn handle_request_inner(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
356		let path = route_path(&self.settings.base_path, &req.url)?;
357		match (req.method.as_str(), path.as_str()) {
358			("GET", "") | ("GET", "/") => Ok(text_response(
359				StatusCode::OK,
360				"text/plain; charset=utf-8",
361				"This is a RivetKit server.\n\nLearn more at https://rivet.dev",
362			)),
363			("GET", "/health") => {
364				// Healthy if no envoy is connected yet or if the envoy has received a
365				// recent engine ping. Unhealthy only when an envoy exists but has not
366				// received a recent ping. 503 is the conventional "recycle me" signal
367				// for container hosts running behind an HTTP health probe.
368				// An envoy that is still waiting for the engine's init message is
369				// reported healthy. `ensure_envoy` bounds that wait and evicts an
370				// envoy that never starts.
371				let handle = { self.cached_envoy_handle().await };
372				let runtime_healthy = handle
373					.map(|handle| !handle.has_started() || handle.is_ping_healthy())
374					.unwrap_or(true);
375				if runtime_healthy {
376					Ok(json_response(
377						StatusCode::OK,
378						json!({
379							"status": "ok",
380							"runtime": "rivetkit",
381							"version": self.settings.package_version,
382						}),
383					))
384				} else {
385					Ok(json_response(
386						StatusCode::SERVICE_UNAVAILABLE,
387						json!({
388							"status": "engine_ping_stale",
389							"runtime": "rivetkit",
390							"version": self.settings.package_version,
391						}),
392					))
393				}
394			}
395			("GET", "/metadata") => Ok(self.metadata_response()),
396			("GET", "/start") | ("POST", "/start") => self.start_response(req).await,
397			("OPTIONS", _) => Ok(bytes_response(
398				StatusCode::NO_CONTENT,
399				HashMap::new(),
400				Vec::new(),
401			)),
402			_ => Ok(text_response(
403				StatusCode::NOT_FOUND,
404				"text/plain; charset=utf-8",
405				"Not Found (RivetKit)",
406			)),
407		}
408	}
409
410	async fn start_response(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
411		let headers = parse_start_headers(&req.headers)?;
412		self.validate_start_headers(&headers)?;
413		crate::metrics::record_rivetkit_info(
414			self.settings.package_version.clone(),
415			self.settings.version,
416			"serverless",
417			headers.pool_name.clone(),
418		);
419		#[cfg(not(target_arch = "wasm32"))]
420		crate::tokio_runtime_metrics::ensure_sampler_started();
421		if req.body.len() > self.settings.max_start_payload_bytes {
422			return Err(IncomingMessageTooLong {
423				limit: self.settings.max_start_payload_bytes,
424			}
425			.build());
426		}
427
428		let handle = self.ensure_envoy(&headers).await?;
429		let payload = req.body;
430		let actor_start = handle.decode_serverless_actor_start(&payload)?;
431		let cancel_token = req.cancel_token;
432		let cache_envoy = self.settings.cache_envoy;
433		let (tx, rx) = mpsc::unbounded_channel();
434		let _ = tx.send(Ok(SSE_PING_FRAME.to_vec()));
435
436		RuntimeSpawner::spawn(async move {
437			let shutdown_handle = handle.clone();
438			let result = tokio::select! {
439				_ = cancel_token.cancelled() => {
440					if !cache_envoy {
441						shutdown_handle.shutdown_and_wait(false).await;
442					}
443					return;
444				}
445				result = handle.start_serverless_actor(&payload) => result,
446			};
447			if let Err(error) = result {
448				let error = stream_error(error);
449				let _ = tx.send(Err(error));
450				if !cache_envoy {
451					handle.shutdown_and_wait(false).await;
452				}
453				return;
454			}
455
456			loop {
457				tokio::select! {
458					_ = cancel_token.cancelled() => {
459						break;
460					}
461					_ = handle.wait_actor_registered_then_stopped(&actor_start.actor_id, actor_start.generation) => {
462						let _ = tx.send(Ok(SSE_STOPPING_FRAME.to_vec()));
463						break;
464					}
465					_ = sleep(SSE_PING_INTERVAL) => {
466						if tx.send(Ok(SSE_PING_FRAME.to_vec())).is_err() {
467							break;
468						}
469					}
470				}
471			}
472
473			if !cache_envoy {
474				handle.shutdown_and_wait(false).await;
475			}
476		});
477
478		Ok(ServerlessResponse {
479			status: StatusCode::OK.as_u16(),
480			headers: HashMap::from([
481				("content-type".to_owned(), "text/event-stream".to_owned()),
482				("cache-control".to_owned(), "no-cache".to_owned()),
483				("connection".to_owned(), "keep-alive".to_owned()),
484			]),
485			body: rx,
486		})
487	}
488
489	fn metadata_response(&self) -> ServerlessResponse {
490		let actor_names = self
491			.dispatcher
492			.build_actor_metadata_map()
493			.into_iter()
494			.map(|(name, metadata)| {
495				(
496					name,
497					ActorName {
498						metadata: Some(metadata),
499					},
500				)
501			})
502			.collect::<HashMap<_, _>>();
503
504		let payload = ServerlessMetadataPayload {
505			runtime: "rivetkit".to_owned(),
506			version: self.settings.package_version.clone(),
507			envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
508			actor_names,
509			envoy: Some(ServerlessMetadataEnvoy {
510				kind: Some(ServerlessMetadataEnvoyKind::Serverless {}),
511				version: Some(self.settings.version),
512			}),
513			runner: None,
514			client_endpoint: self.settings.client_endpoint.clone(),
515			client_namespace: self.settings.client_namespace.clone(),
516			client_token: self.settings.client_token.clone(),
517		};
518
519		let response = serde_json::to_value(payload).unwrap_or_else(|_| json!({}));
520
521		json_response(StatusCode::OK, response)
522	}
523
524	fn validate_start_headers(&self, headers: &StartHeaders) -> Result<()> {
525		if self.settings.validate_endpoint {
526			if !endpoints_match(&headers.endpoint, &self.settings.configured_endpoint) {
527				tracing::warn!(
528					configured_endpoint = %self.settings.configured_endpoint,
529					received_endpoint = %headers.endpoint,
530					"serverless start rejected: endpoint mismatch",
531				);
532				return Err(EndpointMismatch {
533					expected: self.settings.configured_endpoint.clone(),
534					received: headers.endpoint.clone(),
535				}
536				.build());
537			}
538
539			if headers.namespace != self.settings.configured_namespace {
540				tracing::warn!(
541					configured_namespace = %self.settings.configured_namespace,
542					received_namespace = %headers.namespace,
543					"serverless start rejected: namespace mismatch",
544				);
545				return Err(NamespaceMismatch {
546					expected: self.settings.configured_namespace.clone(),
547					received: headers.namespace.clone(),
548				}
549				.build());
550			}
551		}
552
553		Ok(())
554	}
555
556	/// Returns a started envoy for these start headers.
557	///
558	/// The envoy mutex is held only to read or install the cached handle, never
559	/// while waiting for the engine. `/health`, other `/start` requests and
560	/// `shutdown` therefore stay responsive while a cold start waits for init.
561	async fn ensure_envoy(&self, headers: &StartHeaders) -> Result<EnvoyHandle> {
562		if self.shutting_down.load(Ordering::Acquire) {
563			return Err(RuntimeShutDown.build());
564		}
565		if !self.settings.cache_envoy {
566			let handle = start_envoy_sync(self.envoy_config(headers));
567			let start_deadline = Instant::now() + ENVOY_START_TIMEOUT;
568			if let Err(reason) = wait_for_envoy_start(&handle, start_deadline).await {
569				return Err(self.stop_failed_envoy(&handle, reason).await);
570			}
571			return Ok(handle);
572		}
573
574		let cached = {
575			let mut guard = self.envoy.lock().await;
576			// `shutdown` sets the flag before it takes the cached handle under this
577			// lock, so checking here guarantees it either sees the handle installed
578			// below or this call sees the flag.
579			if self.shutting_down.load(Ordering::Acquire) {
580				return Err(RuntimeShutDown.build());
581			}
582			match guard.as_ref() {
583				Some(cached) => {
584					// The start request token authenticates the serverless callback. It is
585					// not part of envoy identity, and may differ from the token used for
586					// the engine connection.
587					let handle = &cached.handle;
588					if !endpoints_match(handle.endpoint(), &headers.endpoint)
589						|| handle.namespace() != headers.namespace
590						|| handle.pool_name() != headers.pool_name
591					{
592						anyhow::bail!("serverless start headers do not match active envoy");
593					}
594					cached.clone()
595				}
596				None => {
597					let cached = CachedEnvoy {
598						handle: start_envoy_sync(self.envoy_config(headers)),
599						start_deadline: Instant::now() + ENVOY_START_TIMEOUT,
600					};
601					*guard = Some(cached.clone());
602					cached
603				}
604			}
605		};
606
607		if let Err(reason) = wait_for_envoy_start(&cached.handle, cached.start_deadline).await {
608			// Evict before stopping, so `/start` requests that arrive while the
609			// failed envoy drains start a fresh one instead of receiving it.
610			{
611				let mut guard = self.envoy.lock().await;
612				let is_cached = guard.as_ref().is_some_and(|current| {
613					current.handle.get_envoy_key() == cached.handle.get_envoy_key()
614				});
615				if is_cached {
616					*guard = None;
617				}
618			}
619			return Err(self.stop_failed_envoy(&cached.handle, reason).await);
620		}
621		Ok(cached.handle)
622	}
623
624	/// Stops an envoy that missed its start deadline so its reconnect loop does
625	/// not outlive the request, and returns the error for the caller.
626	async fn stop_failed_envoy(&self, handle: &EnvoyHandle, reason: String) -> anyhow::Error {
627		handle.shutdown(true);
628		if timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.wait_stopped())
629			.await
630			.is_err()
631		{
632			tracing::warn!(
633				"serverless envoy did not stop after a failed start; the engine may be unreachable"
634			);
635		}
636
637		if self.shutting_down.load(Ordering::Acquire) {
638			return RuntimeShutDown.build();
639		}
640		tracing::warn!(%reason, "serverless envoy failed to start; check engine reachability");
641		EnvoyStartFailed { reason }.build()
642	}
643
644	fn envoy_config(&self, headers: &StartHeaders) -> EnvoyConfig {
645		let callbacks = Arc::new(RegistryCallbacks {
646			dispatcher: self.dispatcher.clone(),
647		});
648		let prepopulate_actor_names = self
649			.dispatcher
650			.build_actor_metadata_map()
651			.into_iter()
652			.map(|(name, metadata)| (name, EnvoyActorName { metadata }))
653			.collect();
654		// not_global: true to avoid caching the handle in the process-wide
655		// `GLOBAL_ENVOY` OnceLock. Without this, a shutdown-during-build race
656		// leaves a dead handle cached for the life of the process and any
657		// subsequent consumer gets it back.
658		EnvoyConfig {
659			version: self.settings.version,
660			endpoint: headers.endpoint.clone(),
661			token: headers.token.clone(),
662			namespace: headers.namespace.clone(),
663			pool_name: headers.pool_name.clone(),
664			prepopulate_actor_names,
665			metadata: Some(json!({
666				"rivetkit": { "version": self.settings.package_version },
667			})),
668			not_global: true,
669			debug_latency_ms: None,
670			max_queued_requests_per_actor:
671				rivet_envoy_client::config::DEFAULT_MAX_QUEUED_REQUESTS_PER_ACTOR,
672			stopped_generation_floor_ttl:
673				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_TTL,
674			stopped_generation_floor_capacity:
675				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_CAPACITY,
676			callbacks,
677			connector: None,
678		}
679	}
680}
681
682fn route_path(base_path: &str, url: &str) -> Result<String> {
683	let parsed = Url::parse(url).with_context(|| format!("parse request URL `{url}`"))?;
684	let path = parsed.path();
685	if path == base_path {
686		return Ok(String::new());
687	}
688	let prefix = format!("{base_path}/");
689	if let Some(rest) = path.strip_prefix(&prefix) {
690		return Ok(format!("/{rest}"));
691	}
692	Ok(path.to_owned())
693}
694
695fn handles_listener_request(base_path: &str, url: &str) -> bool {
696	let Ok(parsed) = Url::parse(url) else {
697		// Let the normal framework handler return its structured invalid URL
698		// response rather than passing malformed input to user code.
699		return true;
700	};
701	let request_path = parsed.path();
702	if request_path != base_path && !request_path.starts_with(&format!("{base_path}/")) {
703		return false;
704	}
705	let path = route_path(base_path, url).expect("URL was parsed and the same base path is valid");
706	matches!(path.as_str(), "" | "/" | "/health" | "/metadata" | "/start")
707}
708
709fn parse_start_headers(headers: &HashMap<String, String>) -> Result<StartHeaders> {
710	let pool_name = match optional_header(headers, "x-rivet-pool-name") {
711		Some(pool_name) => pool_name,
712		None => optional_header(headers, "x-rivet-runner-name").ok_or_else(|| {
713			InvalidRequest {
714				reason: "x-rivet-pool-name header is required".to_string(),
715			}
716			.build()
717		})?,
718	};
719
720	Ok(StartHeaders {
721		endpoint: required_header(headers, "x-rivet-endpoint")?,
722		token: optional_header(headers, "x-rivet-token"),
723		pool_name,
724		namespace: required_header(headers, "x-rivet-namespace-name")?,
725	})
726}
727
728fn required_header(headers: &HashMap<String, String>, name: &str) -> Result<String> {
729	headers
730		.get(name)
731		.filter(|value| !value.is_empty())
732		.cloned()
733		.ok_or_else(|| {
734			InvalidRequest {
735				reason: format!("{name} header is required"),
736			}
737			.build()
738		})
739}
740
741fn optional_header(headers: &HashMap<String, String>, name: &str) -> Option<String> {
742	headers.get(name).filter(|value| !value.is_empty()).cloned()
743}
744
745fn cors_headers(req: &ServerlessRequest) -> HashMap<String, String> {
746	let origin = req
747		.headers
748		.get("origin")
749		.cloned()
750		.unwrap_or_else(|| "*".to_owned());
751	let mut headers = HashMap::from([
752		("access-control-allow-origin".to_owned(), origin.clone()),
753		(
754			"access-control-allow-credentials".to_owned(),
755			"true".to_owned(),
756		),
757		("access-control-expose-headers".to_owned(), "*".to_owned()),
758	]);
759	if origin != "*" {
760		headers.insert("vary".to_owned(), "Origin".to_owned());
761	}
762
763	if req.method == "OPTIONS" {
764		headers.insert(
765			"access-control-allow-methods".to_owned(),
766			"GET, POST, PUT, DELETE, OPTIONS, PATCH".to_owned(),
767		);
768		headers.insert(
769			"access-control-allow-headers".to_owned(),
770			req.headers
771				.get("access-control-request-headers")
772				.cloned()
773				.unwrap_or_else(|| "*".to_owned()),
774		);
775		headers.insert("access-control-max-age".to_owned(), "86400".to_owned());
776	}
777
778	headers
779}
780
781fn apply_cors(headers: &mut HashMap<String, String>, cors: HashMap<String, String>) {
782	headers.extend(cors);
783}
784
785fn normalize_base_path(base_path: Option<&str>) -> String {
786	let base_path = base_path
787		.filter(|base_path| !base_path.is_empty())
788		.unwrap_or(DEFAULT_BASE_PATH);
789	let prefixed = if base_path.starts_with('/') {
790		base_path.to_owned()
791	} else {
792		format!("/{base_path}")
793	};
794	let trimmed = prefixed.trim_end_matches('/');
795	if trimmed.is_empty() {
796		"/".to_owned()
797	} else {
798		trimmed.to_owned()
799	}
800}
801
802fn text_response(status: StatusCode, content_type: &str, body: &str) -> ServerlessResponse {
803	bytes_response(
804		status,
805		HashMap::from([("content-type".to_owned(), content_type.to_owned())]),
806		body.as_bytes().to_vec(),
807	)
808}
809
810fn json_response(status: StatusCode, body: serde_json::Value) -> ServerlessResponse {
811	bytes_response(
812		status,
813		HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
814		serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
815	)
816}
817
818fn bytes_response(
819	status: StatusCode,
820	headers: HashMap<String, String>,
821	body: Vec<u8>,
822) -> ServerlessResponse {
823	let (tx, rx) = mpsc::unbounded_channel();
824	let _ = tx.send(Ok(body));
825	ServerlessResponse {
826		status: status.as_u16(),
827		headers,
828		body: rx,
829	}
830}
831
832fn error_response(error: anyhow::Error) -> ServerlessResponse {
833	let extracted = rivet_error::RivetError::extract(&error);
834	let status = serverless_error_status(extracted.group(), extracted.code());
835	let body = ServerlessErrorBody {
836		group: extracted.group(),
837		code: extracted.code(),
838		message: extracted.message().to_owned(),
839		metadata: extracted.metadata().unwrap_or(serde_json::Value::Null),
840	};
841	bytes_response(
842		status,
843		HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
844		serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
845	)
846}
847
848fn serverless_error_status(group: &str, code: &str) -> StatusCode {
849	match (group, code) {
850		("auth", "forbidden") => StatusCode::FORBIDDEN,
851		("message", "incoming_too_long") => StatusCode::PAYLOAD_TOO_LARGE,
852		("registry", "envoy_start_failed") => StatusCode::SERVICE_UNAVAILABLE,
853		_ => StatusCode::BAD_REQUEST,
854	}
855}
856
857fn stream_error(error: anyhow::Error) -> ServerlessStreamError {
858	let extracted = rivet_error::RivetError::extract(&error);
859	ServerlessStreamError {
860		group: extracted.group().to_owned(),
861		code: extracted.code().to_owned(),
862		message: extracted.message().to_owned(),
863	}
864}
865
866pub fn normalize_endpoint_url(url: &str) -> Option<String> {
867	let parsed = Url::parse(url).ok()?;
868	let pathname = if parsed.path() == "/" {
869		"/".to_owned()
870	} else {
871		parsed.path().trim_end_matches('/').to_owned()
872	};
873	let mut hostname = parsed.host_str()?.to_owned();
874	if is_loopback_address(&hostname) {
875		hostname = "localhost".to_owned();
876	}
877	hostname = normalize_regional_hostname(&hostname);
878	let host = match parsed.port() {
879		Some(port) => format!("{hostname}:{port}"),
880		None => hostname,
881	};
882	Some(format!("{}://{}{}", parsed.scheme(), host, pathname))
883}
884
885fn normalized_endpoint_candidates(value: &str) -> Vec<String> {
886	value
887		.split(',')
888		.map(str::trim)
889		.filter(|candidate| !candidate.is_empty())
890		.map(|candidate| normalize_endpoint_url(candidate).unwrap_or_else(|| candidate.to_owned()))
891		.collect()
892}
893
894/// Waits until `start_deadline` for the engine's init message and returns the
895/// failure reason when it does not arrive.
896async fn wait_for_envoy_start(
897	handle: &EnvoyHandle,
898	start_deadline: Instant,
899) -> std::result::Result<(), String> {
900	let remaining = start_deadline.saturating_duration_since(Instant::now());
901	match timeout(remaining, handle.started()).await {
902		Ok(Ok(())) => Ok(()),
903		Ok(Err(error)) => Err(format!("{error:#}")),
904		Err(_) => Err(format!(
905			"no init message within {} ms",
906			ENVOY_START_TIMEOUT.as_millis()
907		)),
908	}
909}
910
911pub fn endpoints_match(a: &str, b: &str) -> bool {
912	let a_candidates = normalized_endpoint_candidates(a);
913	let b_candidates = normalized_endpoint_candidates(b);
914	a_candidates.iter().any(|a_candidate| {
915		b_candidates
916			.iter()
917			.any(|b_candidate| a_candidate == b_candidate)
918	})
919}
920
921fn normalize_regional_hostname(hostname: &str) -> String {
922	if !hostname.ends_with(".rivet.dev") || !hostname.starts_with("api-") {
923		return hostname.to_owned();
924	}
925	let without_prefix = &hostname[4..];
926	let Some(first_dot_index) = without_prefix.find('.') else {
927		return hostname.to_owned();
928	};
929	let domain = &without_prefix[first_dot_index + 1..];
930	format!("api.{domain}")
931}
932
933fn is_loopback_address(hostname: &str) -> bool {
934	matches!(hostname, "127.0.0.1" | "0.0.0.0" | "::1" | "[::1]")
935}
936
937// Test shim keeps moved tests in crate-root tests/ with private-module access.
938