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. A terminal
370				// registration rejection stays unhealthy until the host restarts.
371				let handle = { self.cached_envoy_handle().await };
372				let status = match handle {
373					Some(handle) if handle.terminal_error().is_some() => "registration_rejected",
374					Some(handle) if handle.has_started() && !handle.is_ping_healthy() => {
375						"engine_ping_stale"
376					}
377					_ => "ok",
378				};
379				if status == "ok" {
380					Ok(json_response(
381						StatusCode::OK,
382						json!({
383							"status": "ok",
384							"runtime": "rivetkit",
385							"version": self.settings.package_version,
386						}),
387					))
388				} else {
389					Ok(json_response(
390						StatusCode::SERVICE_UNAVAILABLE,
391						json!({
392							"status": status,
393							"runtime": "rivetkit",
394							"version": self.settings.package_version,
395						}),
396					))
397				}
398			}
399			("GET", "/metadata") => Ok(self.metadata_response()),
400			("GET", "/start") | ("POST", "/start") => self.start_response(req).await,
401			("OPTIONS", _) => Ok(bytes_response(
402				StatusCode::NO_CONTENT,
403				HashMap::new(),
404				Vec::new(),
405			)),
406			_ => Ok(text_response(
407				StatusCode::NOT_FOUND,
408				"text/plain; charset=utf-8",
409				"Not Found (RivetKit)",
410			)),
411		}
412	}
413
414	async fn start_response(&self, req: ServerlessRequest) -> Result<ServerlessResponse> {
415		let headers = parse_start_headers(&req.headers)?;
416		self.validate_start_headers(&headers)?;
417		crate::metrics::record_rivetkit_info(
418			self.settings.package_version.clone(),
419			self.settings.version,
420			"serverless",
421			headers.pool_name.clone(),
422		);
423		#[cfg(not(target_arch = "wasm32"))]
424		crate::tokio_runtime_metrics::ensure_sampler_started();
425		if req.body.len() > self.settings.max_start_payload_bytes {
426			return Err(IncomingMessageTooLong {
427				limit: self.settings.max_start_payload_bytes,
428			}
429			.build());
430		}
431
432		let handle = self.ensure_envoy(&headers).await?;
433		let payload = req.body;
434		let actor_start = handle.decode_serverless_actor_start(&payload)?;
435		let cancel_token = req.cancel_token;
436		let cache_envoy = self.settings.cache_envoy;
437		let (tx, rx) = mpsc::unbounded_channel();
438		let _ = tx.send(Ok(SSE_PING_FRAME.to_vec()));
439
440		RuntimeSpawner::spawn(async move {
441			let shutdown_handle = handle.clone();
442			let result = tokio::select! {
443				_ = cancel_token.cancelled() => {
444					if !cache_envoy {
445						shutdown_handle.shutdown_and_wait(false).await;
446					}
447					return;
448				}
449				result = handle.start_serverless_actor(&payload) => result,
450			};
451			if let Err(error) = result {
452				let error = stream_error(error);
453				let _ = tx.send(Err(error));
454				if !cache_envoy {
455					handle.shutdown_and_wait(false).await;
456				}
457				return;
458			}
459
460			loop {
461				tokio::select! {
462					_ = cancel_token.cancelled() => {
463						break;
464					}
465					_ = handle.wait_actor_registered_then_stopped(&actor_start.actor_id, actor_start.generation) => {
466						let _ = tx.send(Ok(SSE_STOPPING_FRAME.to_vec()));
467						break;
468					}
469					_ = sleep(SSE_PING_INTERVAL) => {
470						if tx.send(Ok(SSE_PING_FRAME.to_vec())).is_err() {
471							break;
472						}
473					}
474				}
475			}
476
477			if !cache_envoy {
478				handle.shutdown_and_wait(false).await;
479			}
480		});
481
482		Ok(ServerlessResponse {
483			status: StatusCode::OK.as_u16(),
484			headers: HashMap::from([
485				("content-type".to_owned(), "text/event-stream".to_owned()),
486				("cache-control".to_owned(), "no-cache".to_owned()),
487				("connection".to_owned(), "keep-alive".to_owned()),
488			]),
489			body: rx,
490		})
491	}
492
493	fn metadata_response(&self) -> ServerlessResponse {
494		let actor_names = self
495			.dispatcher
496			.build_actor_metadata_map()
497			.into_iter()
498			.map(|(name, metadata)| {
499				(
500					name,
501					ActorName {
502						metadata: Some(metadata),
503					},
504				)
505			})
506			.collect::<HashMap<_, _>>();
507
508		let payload = ServerlessMetadataPayload {
509			runtime: "rivetkit".to_owned(),
510			version: self.settings.package_version.clone(),
511			envoy_protocol_version: Some(protocol::PROTOCOL_VERSION),
512			actor_names,
513			envoy: Some(ServerlessMetadataEnvoy {
514				kind: Some(ServerlessMetadataEnvoyKind::Serverless {}),
515				version: Some(self.settings.version),
516			}),
517			runner: None,
518			client_endpoint: self.settings.client_endpoint.clone(),
519			client_namespace: self.settings.client_namespace.clone(),
520			client_token: self.settings.client_token.clone(),
521		};
522
523		let response = serde_json::to_value(payload).unwrap_or_else(|_| json!({}));
524
525		json_response(StatusCode::OK, response)
526	}
527
528	fn validate_start_headers(&self, headers: &StartHeaders) -> Result<()> {
529		if self.settings.validate_endpoint {
530			if !endpoints_match(&headers.endpoint, &self.settings.configured_endpoint) {
531				tracing::warn!(
532					configured_endpoint = %self.settings.configured_endpoint,
533					received_endpoint = %headers.endpoint,
534					"serverless start rejected: endpoint mismatch",
535				);
536				return Err(EndpointMismatch {
537					expected: self.settings.configured_endpoint.clone(),
538					received: headers.endpoint.clone(),
539				}
540				.build());
541			}
542
543			if headers.namespace != self.settings.configured_namespace {
544				tracing::warn!(
545					configured_namespace = %self.settings.configured_namespace,
546					received_namespace = %headers.namespace,
547					"serverless start rejected: namespace mismatch",
548				);
549				return Err(NamespaceMismatch {
550					expected: self.settings.configured_namespace.clone(),
551					received: headers.namespace.clone(),
552				}
553				.build());
554			}
555		}
556
557		Ok(())
558	}
559
560	/// Returns a started envoy for these start headers.
561	///
562	/// The envoy mutex is held only to read or install the cached handle, never
563	/// while waiting for the engine. `/health`, other `/start` requests and
564	/// `shutdown` therefore stay responsive while a cold start waits for init.
565	async fn ensure_envoy(&self, headers: &StartHeaders) -> Result<EnvoyHandle> {
566		if self.shutting_down.load(Ordering::Acquire) {
567			return Err(RuntimeShutDown.build());
568		}
569		if !self.settings.cache_envoy {
570			let handle = start_envoy_sync(self.envoy_config(headers));
571			let start_deadline = Instant::now() + ENVOY_START_TIMEOUT;
572			if let Err(reason) = wait_for_envoy_start(&handle, start_deadline).await {
573				return Err(self.stop_failed_envoy(&handle, reason).await);
574			}
575			return Ok(handle);
576		}
577
578		let cached = {
579			let mut guard = self.envoy.lock().await;
580			// `shutdown` sets the flag before it takes the cached handle under this
581			// lock, so checking here guarantees it either sees the handle installed
582			// below or this call sees the flag.
583			if self.shutting_down.load(Ordering::Acquire) {
584				return Err(RuntimeShutDown.build());
585			}
586			match guard.as_ref() {
587				Some(cached) => {
588					// The start request token authenticates the serverless callback. It is
589					// not part of envoy identity, and may differ from the token used for
590					// the engine connection.
591					let handle = &cached.handle;
592					if !endpoints_match(handle.endpoint(), &headers.endpoint)
593						|| handle.namespace() != headers.namespace
594						|| handle.pool_name() != headers.pool_name
595					{
596						anyhow::bail!("serverless start headers do not match active envoy");
597					}
598					cached.clone()
599				}
600				None => {
601					let cached = CachedEnvoy {
602						handle: start_envoy_sync(self.envoy_config(headers)),
603						start_deadline: Instant::now() + ENVOY_START_TIMEOUT,
604					};
605					*guard = Some(cached.clone());
606					cached
607				}
608			}
609		};
610
611		if let Err(reason) = wait_for_envoy_start(&cached.handle, cached.start_deadline).await {
612			// Registration rejection is terminal for this runtime. Keep the diagnostic
613			// cached until restart, so another request cannot register a replacement.
614			if let Some(error) = cached.handle.terminal_error() {
615				return Err(anyhow::anyhow!(error));
616			}
617			// Evict before stopping, so `/start` requests that arrive while the
618			// failed envoy drains start a fresh one instead of receiving it.
619			{
620				let mut guard = self.envoy.lock().await;
621				let is_cached = guard.as_ref().is_some_and(|current| {
622					current.handle.get_envoy_key() == cached.handle.get_envoy_key()
623				});
624				if is_cached {
625					*guard = None;
626				}
627			}
628			return Err(self.stop_failed_envoy(&cached.handle, reason).await);
629		}
630		Ok(cached.handle)
631	}
632
633	/// Stops an envoy that cannot serve a start request so its reconnect loop does not
634	/// outlive the request. Preserves terminal registration errors for the caller.
635	async fn stop_failed_envoy(&self, handle: &EnvoyHandle, reason: String) -> anyhow::Error {
636		handle.shutdown(true);
637		if timeout(SHUTDOWN_DRAIN_TIMEOUT, handle.wait_stopped())
638			.await
639			.is_err()
640		{
641			tracing::warn!(
642				"serverless envoy did not stop after a failed start; the engine may be unreachable"
643			);
644		}
645
646		if self.shutting_down.load(Ordering::Acquire) {
647			return RuntimeShutDown.build();
648		}
649		if let Some(error) = handle.terminal_error() {
650			return anyhow::anyhow!(error);
651		}
652		tracing::warn!(%reason, "serverless envoy failed to start; check engine reachability");
653		EnvoyStartFailed { reason }.build()
654	}
655
656	fn envoy_config(&self, headers: &StartHeaders) -> EnvoyConfig {
657		let callbacks = Arc::new(RegistryCallbacks {
658			dispatcher: self.dispatcher.clone(),
659		});
660		let prepopulate_actor_names = self
661			.dispatcher
662			.build_actor_metadata_map()
663			.into_iter()
664			.map(|(name, metadata)| (name, EnvoyActorName { metadata }))
665			.collect();
666		// not_global: true to avoid caching the handle in the process-wide
667		// `GLOBAL_ENVOY` OnceLock. Without this, a shutdown-during-build race
668		// leaves a dead handle cached for the life of the process and any
669		// subsequent consumer gets it back.
670		EnvoyConfig {
671			version: self.settings.version,
672			endpoint: headers.endpoint.clone(),
673			token: headers.token.clone(),
674			namespace: headers.namespace.clone(),
675			pool_name: headers.pool_name.clone(),
676			prepopulate_actor_names,
677			metadata: Some(json!({
678				"rivetkit": { "version": self.settings.package_version },
679			})),
680			not_global: true,
681			debug_latency_ms: None,
682			max_queued_requests_per_actor:
683				rivet_envoy_client::config::DEFAULT_MAX_QUEUED_REQUESTS_PER_ACTOR,
684			stopped_generation_floor_ttl:
685				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_TTL,
686			stopped_generation_floor_capacity:
687				rivet_envoy_client::config::DEFAULT_STOPPED_GENERATION_FLOOR_CAPACITY,
688			callbacks,
689			connector: None,
690		}
691	}
692}
693
694fn route_path(base_path: &str, url: &str) -> Result<String> {
695	let parsed = Url::parse(url).with_context(|| format!("parse request URL `{url}`"))?;
696	let path = parsed.path();
697	if path == base_path {
698		return Ok(String::new());
699	}
700	let prefix = format!("{base_path}/");
701	if let Some(rest) = path.strip_prefix(&prefix) {
702		return Ok(format!("/{rest}"));
703	}
704	Ok(path.to_owned())
705}
706
707fn handles_listener_request(base_path: &str, url: &str) -> bool {
708	let Ok(parsed) = Url::parse(url) else {
709		// Let the normal framework handler return its structured invalid URL
710		// response rather than passing malformed input to user code.
711		return true;
712	};
713	let request_path = parsed.path();
714	if request_path != base_path && !request_path.starts_with(&format!("{base_path}/")) {
715		return false;
716	}
717	let path = route_path(base_path, url).expect("URL was parsed and the same base path is valid");
718	matches!(path.as_str(), "" | "/" | "/health" | "/metadata" | "/start")
719}
720
721fn parse_start_headers(headers: &HashMap<String, String>) -> Result<StartHeaders> {
722	let pool_name = match optional_header(headers, "x-rivet-pool-name") {
723		Some(pool_name) => pool_name,
724		None => optional_header(headers, "x-rivet-runner-name").ok_or_else(|| {
725			InvalidRequest {
726				reason: "x-rivet-pool-name header is required".to_string(),
727			}
728			.build()
729		})?,
730	};
731
732	Ok(StartHeaders {
733		endpoint: required_header(headers, "x-rivet-endpoint")?,
734		token: optional_header(headers, "x-rivet-token"),
735		pool_name,
736		namespace: required_header(headers, "x-rivet-namespace-name")?,
737	})
738}
739
740fn required_header(headers: &HashMap<String, String>, name: &str) -> Result<String> {
741	headers
742		.get(name)
743		.filter(|value| !value.is_empty())
744		.cloned()
745		.ok_or_else(|| {
746			InvalidRequest {
747				reason: format!("{name} header is required"),
748			}
749			.build()
750		})
751}
752
753fn optional_header(headers: &HashMap<String, String>, name: &str) -> Option<String> {
754	headers.get(name).filter(|value| !value.is_empty()).cloned()
755}
756
757fn cors_headers(req: &ServerlessRequest) -> HashMap<String, String> {
758	let origin = req
759		.headers
760		.get("origin")
761		.cloned()
762		.unwrap_or_else(|| "*".to_owned());
763	let mut headers = HashMap::from([
764		("access-control-allow-origin".to_owned(), origin.clone()),
765		(
766			"access-control-allow-credentials".to_owned(),
767			"true".to_owned(),
768		),
769		("access-control-expose-headers".to_owned(), "*".to_owned()),
770	]);
771	if origin != "*" {
772		headers.insert("vary".to_owned(), "Origin".to_owned());
773	}
774
775	if req.method == "OPTIONS" {
776		headers.insert(
777			"access-control-allow-methods".to_owned(),
778			"GET, POST, PUT, DELETE, OPTIONS, PATCH".to_owned(),
779		);
780		headers.insert(
781			"access-control-allow-headers".to_owned(),
782			req.headers
783				.get("access-control-request-headers")
784				.cloned()
785				.unwrap_or_else(|| "*".to_owned()),
786		);
787		headers.insert("access-control-max-age".to_owned(), "86400".to_owned());
788	}
789
790	headers
791}
792
793fn apply_cors(headers: &mut HashMap<String, String>, cors: HashMap<String, String>) {
794	headers.extend(cors);
795}
796
797fn normalize_base_path(base_path: Option<&str>) -> String {
798	let base_path = base_path
799		.filter(|base_path| !base_path.is_empty())
800		.unwrap_or(DEFAULT_BASE_PATH);
801	let prefixed = if base_path.starts_with('/') {
802		base_path.to_owned()
803	} else {
804		format!("/{base_path}")
805	};
806	let trimmed = prefixed.trim_end_matches('/');
807	if trimmed.is_empty() {
808		"/".to_owned()
809	} else {
810		trimmed.to_owned()
811	}
812}
813
814fn text_response(status: StatusCode, content_type: &str, body: &str) -> ServerlessResponse {
815	bytes_response(
816		status,
817		HashMap::from([("content-type".to_owned(), content_type.to_owned())]),
818		body.as_bytes().to_vec(),
819	)
820}
821
822fn json_response(status: StatusCode, body: serde_json::Value) -> ServerlessResponse {
823	bytes_response(
824		status,
825		HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
826		serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
827	)
828}
829
830fn bytes_response(
831	status: StatusCode,
832	headers: HashMap<String, String>,
833	body: Vec<u8>,
834) -> ServerlessResponse {
835	let (tx, rx) = mpsc::unbounded_channel();
836	let _ = tx.send(Ok(body));
837	ServerlessResponse {
838		status: status.as_u16(),
839		headers,
840		body: rx,
841	}
842}
843
844fn error_response(error: anyhow::Error) -> ServerlessResponse {
845	let extracted = rivet_error::RivetError::extract(&error);
846	let status = serverless_error_status(extracted.group(), extracted.code());
847	let body = ServerlessErrorBody {
848		group: extracted.group(),
849		code: extracted.code(),
850		message: extracted.message().to_owned(),
851		metadata: extracted.metadata().unwrap_or(serde_json::Value::Null),
852	};
853	bytes_response(
854		status,
855		HashMap::from([("content-type".to_owned(), "application/json".to_owned())]),
856		serde_json::to_vec(&body).unwrap_or_else(|_| b"{}".to_vec()),
857	)
858}
859
860fn serverless_error_status(group: &str, code: &str) -> StatusCode {
861	match (group, code) {
862		("auth", "forbidden") => StatusCode::FORBIDDEN,
863		("message", "incoming_too_long") => StatusCode::PAYLOAD_TOO_LARGE,
864		("registry", "envoy_start_failed") => StatusCode::SERVICE_UNAVAILABLE,
865		_ => StatusCode::BAD_REQUEST,
866	}
867}
868
869fn stream_error(error: anyhow::Error) -> ServerlessStreamError {
870	let extracted = rivet_error::RivetError::extract(&error);
871	ServerlessStreamError {
872		group: extracted.group().to_owned(),
873		code: extracted.code().to_owned(),
874		message: extracted.message().to_owned(),
875	}
876}
877
878pub fn normalize_endpoint_url(url: &str) -> Option<String> {
879	let parsed = Url::parse(url).ok()?;
880	let pathname = if parsed.path() == "/" {
881		"/".to_owned()
882	} else {
883		parsed.path().trim_end_matches('/').to_owned()
884	};
885	let mut hostname = parsed.host_str()?.to_owned();
886	if is_loopback_address(&hostname) {
887		hostname = "localhost".to_owned();
888	}
889	hostname = normalize_regional_hostname(&hostname);
890	let host = match parsed.port() {
891		Some(port) => format!("{hostname}:{port}"),
892		None => hostname,
893	};
894	Some(format!("{}://{}{}", parsed.scheme(), host, pathname))
895}
896
897fn normalized_endpoint_candidates(value: &str) -> Vec<String> {
898	value
899		.split(',')
900		.map(str::trim)
901		.filter(|candidate| !candidate.is_empty())
902		.map(|candidate| normalize_endpoint_url(candidate).unwrap_or_else(|| candidate.to_owned()))
903		.collect()
904}
905
906/// Waits until `start_deadline` for the engine's init message and returns the
907/// failure reason when it does not arrive or registration is rejected.
908async fn wait_for_envoy_start(
909	handle: &EnvoyHandle,
910	start_deadline: Instant,
911) -> std::result::Result<(), String> {
912	let remaining = start_deadline.saturating_duration_since(Instant::now());
913	match timeout(remaining, handle.started()).await {
914		Ok(Ok(())) => Ok(()),
915		Ok(Err(error)) => Err(format!("{error:#}")),
916		Err(_) => Err(format!(
917			"no init message within {} ms",
918			ENVOY_START_TIMEOUT.as_millis()
919		)),
920	}
921}
922
923pub fn endpoints_match(a: &str, b: &str) -> bool {
924	let a_candidates = normalized_endpoint_candidates(a);
925	let b_candidates = normalized_endpoint_candidates(b);
926	a_candidates.iter().any(|a_candidate| {
927		b_candidates
928			.iter()
929			.any(|b_candidate| a_candidate == b_candidate)
930	})
931}
932
933fn normalize_regional_hostname(hostname: &str) -> String {
934	if !hostname.ends_with(".rivet.dev") || !hostname.starts_with("api-") {
935		return hostname.to_owned();
936	}
937	let without_prefix = &hostname[4..];
938	let Some(first_dot_index) = without_prefix.find('.') else {
939		return hostname.to_owned();
940	};
941	let domain = &without_prefix[first_dot_index + 1..];
942	format!("api.{domain}")
943}
944
945fn is_loopback_address(hostname: &str) -> bool {
946	matches!(hostname, "127.0.0.1" | "0.0.0.0" | "::1" | "[::1]")
947}
948
949// Test shim keeps moved tests in crate-root tests/ with private-module access.
950