Skip to main content

spin_trigger_http/
lib.rs

1//! Implementation for the Spin HTTP engine.
2
3mod headers;
4mod instrument;
5mod middleware;
6mod outbound_http;
7mod server;
8mod spin;
9mod tls;
10mod wagi;
11mod wasi;
12mod wasip3;
13
14use std::{
15    error::Error,
16    fmt::Display,
17    net::{Ipv4Addr, SocketAddr, ToSocketAddrs},
18    path::PathBuf,
19    str::FromStr,
20    sync::Arc,
21    time::Duration,
22};
23
24use anyhow::{Context, bail};
25use clap::Args;
26use rand::{
27    distr::uniform::{SampleRange, SampleUniform},
28    rand_core::Rng,
29};
30use serde::Deserialize;
31use spin_app::App;
32use spin_factors::RuntimeFactors;
33use spin_trigger::Trigger;
34use wasmtime_wasi_http::p2::bindings::http::types::ErrorCode;
35
36pub use server::HttpServer;
37
38pub use tls::TlsConfig;
39
40pub(crate) use wasmtime_wasi_http::p2::body::HyperIncomingBody as Body;
41
42const DEFAULT_WASIP3_MAX_INSTANCE_REUSE_COUNT: usize = 128;
43const DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT: usize = 16;
44const DEFAULT_REQUEST_TIMEOUT: Option<Range<Duration>> = None;
45const DEFAULT_IDLE_INSTANCE_TIMEOUT: Range<Duration> = Range::Value(Duration::from_secs(1));
46
47/// The format in which to print startup route information.
48#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
49pub enum OutputFormat {
50    /// Human-readable plain text output (the default).
51    #[default]
52    Plain,
53    /// Machine-readable JSON output.
54    Json,
55}
56
57/// A [`spin_trigger::TriggerApp`] for the HTTP trigger.
58pub(crate) type TriggerApp<F> = spin_trigger::TriggerApp<HttpTrigger, F>;
59
60/// A [`spin_trigger::TriggerInstanceBuilder`] for the HTTP trigger.
61pub(crate) type TriggerInstanceBuilder<'a, F> =
62    spin_trigger::TriggerInstanceBuilder<'a, HttpTrigger, F>;
63
64#[derive(Args)]
65pub struct CliArgs {
66    /// IP address and port to listen on
67    #[clap(long = "listen", env = "SPIN_HTTP_LISTEN_ADDR", default_value = "127.0.0.1:3000", value_parser = parse_listen_addr)]
68    pub address: SocketAddr,
69
70    /// The path to the certificate to use for https, if this is not set, normal http will be used. The cert should be in PEM format
71    #[clap(long, env = "SPIN_TLS_CERT", requires = "tls_key")]
72    pub tls_cert: Option<PathBuf>,
73
74    /// The path to the certificate key to use for https, if this is not set, normal http will be used. The key should be in PKCS#8 format
75    #[clap(long, env = "SPIN_TLS_KEY", requires = "tls_cert")]
76    pub tls_key: Option<PathBuf>,
77
78    /// Sets the maximum buffer size (in bytes) for the HTTP connection. The minimum value allowed is 8192.
79    #[clap(long, env = "SPIN_HTTP1_MAX_BUF_SIZE")]
80    pub http1_max_buf_size: Option<usize>,
81
82    #[clap(long = "find-free-port")]
83    pub find_free_port: bool,
84
85    #[clap(value_enum, long = "format", default_value_t = OutputFormat::default())]
86    pub format: OutputFormat,
87
88    /// Maximum number of requests to send to a single component instance before
89    /// dropping it.
90    ///
91    /// This defaults to 1 for WASIp2 components and 128 for WASIp3 components.
92    /// As of this writing, setting it to more than 1 will have no effect for
93    /// WASIp2 components, but that may change in the future.
94    ///
95    /// This may be specified either as an integer value or as a range,
96    /// e.g. 1..8.  If it's a range, a number will be selected from that range
97    /// at random for each new instance.
98    #[clap(long, value_parser = parse_usize_range)]
99    pub max_instance_reuse_count: Option<Range<usize>>,
100
101    /// Maximum number of concurrent requests to send to a single component
102    /// instance.
103    ///
104    /// This defaults to 1 for WASIp2 components and 16 for WASIp3 components.
105    /// Note that setting it to more than 1 will have no effect for WASIp2
106    /// components since they cannot be called concurrently.
107    ///
108    /// This may be specified either as an integer value or as a range,
109    /// e.g. 1..8.  If it's a range, a number will be selected from that range
110    /// at random for each new instance.
111    #[clap(long, value_parser = parse_usize_range)]
112    pub max_instance_concurrent_reuse_count: Option<Range<usize>>,
113
114    /// Request timeout to enforce.
115    ///
116    /// As of this writing, this only affects WASIp3 components.
117    ///
118    /// A number with no suffix or with an `s` suffix is interpreted as seconds;
119    /// other accepted suffixes include `ms` (milliseconds), `us` or `μs`
120    /// (microseconds), and `ns` (nanoseconds).
121    ///
122    /// This may be specified either as a single time value or as a range,
123    /// e.g. 1..8s.  If it's a range, a value will be selected from that range
124    /// at random for each new instance.
125    #[clap(long, value_parser = parse_duration_range)]
126    pub request_timeout: Option<Range<Duration>>,
127
128    /// Time to hold an idle component instance for possible reuse before
129    /// dropping it.
130    ///
131    /// A number with no suffix or with an `s` suffix is interpreted as seconds;
132    /// other accepted suffixes include `ms` (milliseconds), `us` or `μs`
133    /// (microseconds), and `ns` (nanoseconds).
134    ///
135    /// This may be specified either as a single time value or as a range,
136    /// e.g. 1..8s.  If it's a range, a value will be selected from that range
137    /// at random for each new instance.
138    #[clap(long, default_value = "1s", value_parser = parse_duration_range)]
139    pub idle_instance_timeout: Range<Duration>,
140}
141
142impl CliArgs {
143    fn into_tls_config(self) -> Option<TlsConfig> {
144        match (self.tls_cert, self.tls_key) {
145            (Some(cert_path), Some(key_path)) => Some(TlsConfig {
146                cert_path,
147                key_path,
148            }),
149            (None, None) => None,
150            _ => unreachable!(),
151        }
152    }
153}
154
155#[derive(Copy, Clone)]
156pub enum Range<T> {
157    Value(T),
158    Bounds(T, T),
159}
160
161impl<T> Range<T> {
162    fn map<V>(self, fun: impl Fn(T) -> V) -> Range<V> {
163        match self {
164            Self::Value(v) => Range::Value(fun(v)),
165            Self::Bounds(a, b) => Range::Bounds(fun(a), fun(b)),
166        }
167    }
168}
169
170impl<T: SampleUniform + PartialOrd> SampleRange<T> for Range<T> {
171    fn sample_single<R: Rng + ?Sized>(self, rng: &mut R) -> Result<T, rand::distr::uniform::Error> {
172        match self {
173            Self::Value(v) => Ok(v),
174            Self::Bounds(a, b) => (a..b).sample_single(rng),
175        }
176    }
177
178    fn is_empty(&self) -> bool {
179        match self {
180            Self::Value(_) => false,
181            Self::Bounds(a, b) => (a..b).is_empty(),
182        }
183    }
184}
185
186fn parse_range<T: FromStr>(s: &str) -> Result<Range<T>, String>
187where
188    T::Err: Display,
189{
190    let error = |e| format!("expected integer or range; got {s:?}; {e}");
191    if let Some((start, end)) = s.split_once("..") {
192        Ok(Range::Bounds(
193            start.parse().map_err(error)?,
194            end.parse().map_err(error)?,
195        ))
196    } else {
197        Ok(Range::Value(s.parse().map_err(error)?))
198    }
199}
200
201fn parse_usize_range(s: &str) -> Result<Range<usize>, String> {
202    parse_range(s)
203}
204
205struct ParsedDuration(Duration);
206
207impl FromStr for ParsedDuration {
208    type Err = String;
209
210    fn from_str(s: &str) -> Result<Self, Self::Err> {
211        let error = |e| {
212            format!("expected integer suffixed by `s`, `ms`, `us`, `μs`, or `ns`; got {s:?}; {e}")
213        };
214        Ok(Self(match s.parse() {
215            Ok(val) => Duration::from_secs(val),
216            Err(err) => {
217                if let Some(num) = s.strip_suffix("s") {
218                    Duration::from_secs(num.parse().map_err(error)?)
219                } else if let Some(num) = s.strip_suffix("ms") {
220                    Duration::from_millis(num.parse().map_err(error)?)
221                } else if let Some(num) = s.strip_suffix("us").or(s.strip_suffix("μs")) {
222                    Duration::from_micros(num.parse().map_err(error)?)
223                } else if let Some(num) = s.strip_suffix("ns") {
224                    Duration::from_nanos(num.parse().map_err(error)?)
225                } else {
226                    return Err(error(err));
227                }
228            }
229        }))
230    }
231}
232
233fn parse_duration_range(s: &str) -> Result<Range<Duration>, String> {
234    parse_range::<ParsedDuration>(s).map(|v| v.map(|v| v.0))
235}
236
237#[derive(Clone, Copy)]
238pub struct InstanceReuseConfig {
239    max_instance_reuse_count: Range<usize>,
240    max_instance_concurrent_reuse_count: Range<usize>,
241    request_timeout: Option<Range<Duration>>,
242    request_deadline: Option<Duration>,
243    idle_instance_timeout: Range<Duration>,
244}
245
246impl Default for InstanceReuseConfig {
247    fn default() -> Self {
248        Self {
249            max_instance_reuse_count: Range::Value(DEFAULT_WASIP3_MAX_INSTANCE_REUSE_COUNT),
250            max_instance_concurrent_reuse_count: Range::Value(
251                DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT,
252            ),
253            request_timeout: DEFAULT_REQUEST_TIMEOUT,
254            request_deadline: None,
255            idle_instance_timeout: DEFAULT_IDLE_INSTANCE_TIMEOUT,
256        }
257    }
258}
259
260impl InstanceReuseConfig {
261    /// Creates a single-use instance reuse configuration with a Wasmtime request deadline.
262    ///
263    /// The deadline is enforced by the Wasmtime epoch interruption mechanism in the
264    /// underlying Spin store. It is a rough deadline: the guest may run somewhat longer
265    /// depending on the engine epoch tick interval, host thread scheduling, and how often
266    /// the compiled guest code checks the epoch. Instance reuse is disabled so every request
267    /// receives a fresh store with a request-specific deadline.
268    pub fn single_use_with_request_deadline(timeout: Duration) -> Self {
269        Self {
270            max_instance_reuse_count: Range::Value(1),
271            max_instance_concurrent_reuse_count: Range::Value(1),
272            request_timeout: Some(Range::Value(timeout)),
273            request_deadline: Some(timeout),
274            idle_instance_timeout: DEFAULT_IDLE_INSTANCE_TIMEOUT,
275        }
276    }
277}
278
279/// The Spin HTTP trigger.
280pub struct HttpTrigger {
281    /// The address the server should listen on.
282    ///
283    /// Note that this might not be the actual socket address that ends up being bound to.
284    /// If the port is set to 0, the actual address will be determined by the OS.
285    listen_addr: SocketAddr,
286    tls_config: Option<TlsConfig>,
287    find_free_port: bool,
288    http1_max_buf_size: Option<usize>,
289    reuse_config: InstanceReuseConfig,
290    output_format: OutputFormat,
291}
292
293impl<F: RuntimeFactors> Trigger<F> for HttpTrigger {
294    const TYPE: &'static str = "http";
295
296    type CliArgs = CliArgs;
297    type InstanceState = ();
298
299    fn new(cli_args: Self::CliArgs, app: &spin_app::App) -> anyhow::Result<Self> {
300        let find_free_port = cli_args.find_free_port;
301        let http1_max_buf_size = cli_args.http1_max_buf_size;
302        let output_format = cli_args.format;
303        let reuse_config = InstanceReuseConfig {
304            max_instance_reuse_count: cli_args
305                .max_instance_reuse_count
306                .unwrap_or(Range::Value(DEFAULT_WASIP3_MAX_INSTANCE_REUSE_COUNT)),
307            max_instance_concurrent_reuse_count: cli_args
308                .max_instance_concurrent_reuse_count
309                .unwrap_or(Range::Value(
310                    DEFAULT_WASIP3_MAX_INSTANCE_CONCURRENT_REUSE_COUNT,
311                )),
312            request_timeout: cli_args.request_timeout,
313            request_deadline: None,
314            idle_instance_timeout: cli_args.idle_instance_timeout,
315        };
316
317        Self::new(
318            app,
319            cli_args.address,
320            cli_args.into_tls_config(),
321            find_free_port,
322            http1_max_buf_size,
323            reuse_config,
324            output_format,
325        )
326    }
327
328    async fn run(self, trigger_app: TriggerApp<F>) -> anyhow::Result<()> {
329        let server = self.into_server(trigger_app)?;
330
331        server.serve().await?;
332
333        Ok(())
334    }
335
336    fn trigger_dependencies_composer() -> impl spin_factors_executor::TriggerDependenciesComposer {
337        middleware::HttpMiddlewareComposer
338    }
339
340    fn supported_host_requirements() -> Vec<&'static str> {
341        vec![spin_app::locked::SERVICE_CHAINING_KEY]
342    }
343
344    fn display_name() -> String {
345        "HTTP".to_string()
346    }
347}
348
349impl HttpTrigger {
350    /// Create a new `HttpTrigger`.
351    pub fn new(
352        app: &spin_app::App,
353        listen_addr: SocketAddr,
354        tls_config: Option<TlsConfig>,
355        find_free_port: bool,
356        http1_max_buf_size: Option<usize>,
357        reuse_config: InstanceReuseConfig,
358        output_format: OutputFormat,
359    ) -> anyhow::Result<Self> {
360        Self::validate_app(app)?;
361
362        Ok(Self {
363            listen_addr,
364            tls_config,
365            find_free_port,
366            http1_max_buf_size,
367            reuse_config,
368            output_format,
369        })
370    }
371
372    /// Turn this [`HttpTrigger`] into an [`HttpServer`].
373    pub fn into_server<F: RuntimeFactors>(
374        self,
375        trigger_app: TriggerApp<F>,
376    ) -> anyhow::Result<Arc<HttpServer<F>>> {
377        let Self {
378            listen_addr,
379            tls_config,
380            find_free_port,
381            http1_max_buf_size,
382            reuse_config,
383            output_format,
384        } = self;
385        let server = Arc::new(HttpServer::new(
386            listen_addr,
387            tls_config,
388            find_free_port,
389            trigger_app,
390            http1_max_buf_size,
391            reuse_config,
392            output_format,
393        )?);
394        Ok(server)
395    }
396
397    fn validate_app(app: &App) -> anyhow::Result<()> {
398        #[derive(Deserialize)]
399        #[serde(deny_unknown_fields)]
400        struct TriggerMetadata {
401            base: Option<String>,
402        }
403        if let Some(TriggerMetadata { base: Some(base) }) = app.get_trigger_metadata("http")? {
404            if base == "/" {
405                tracing::warn!(
406                    "This application has the deprecated trigger 'base' set to the default value '/'. This may be an error in the future!"
407                );
408            } else {
409                bail!(
410                    "This application is using the deprecated trigger 'base' field. The base must be prepended to each [[trigger.http]]'s 'route'."
411                )
412            }
413        }
414        Ok(())
415    }
416}
417
418fn parse_listen_addr(addr: &str) -> anyhow::Result<SocketAddr> {
419    let addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
420    // Prefer 127.0.0.1 over e.g. [::1] because CHANGE IS HARD
421    if let Some(addr) = addrs
422        .iter()
423        .find(|addr| addr.is_ipv4() && addr.ip() == Ipv4Addr::LOCALHOST)
424    {
425        return Ok(*addr);
426    }
427    // Otherwise, take the first addr (OS preference)
428    addrs.into_iter().next().context("couldn't resolve address")
429}
430
431#[derive(Debug, PartialEq)]
432enum NotFoundRouteKind {
433    Normal(String),
434    WellKnown,
435}
436
437/// Translate a [`hyper::Error`] to a wasi-http `ErrorCode` in the context of a request.
438pub fn hyper_request_error(err: hyper::Error) -> ErrorCode {
439    // If there's a source, we might be able to extract a wasi-http error from it.
440    if let Some(cause) = err.source()
441        && let Some(err) = cause.downcast_ref::<ErrorCode>()
442    {
443        return err.clone();
444    }
445
446    tracing::warn!("hyper request error: {err:?}");
447
448    ErrorCode::HttpProtocolError
449}
450
451pub fn dns_error(rcode: String, info_code: u16) -> ErrorCode {
452    ErrorCode::DnsError(
453        wasmtime_wasi_http::p2::bindings::http::types::DnsErrorPayload {
454            rcode: Some(rcode),
455            info_code: Some(info_code),
456        },
457    )
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    #[test]
465    fn parse_listen_addr_prefers_ipv4() {
466        let addr = parse_listen_addr("localhost:12345").unwrap();
467        assert_eq!(addr.ip(), Ipv4Addr::LOCALHOST);
468        assert_eq!(addr.port(), 12345);
469    }
470
471    #[test]
472    fn request_deadline_config_is_single_use() {
473        let timeout = Duration::from_millis(500);
474        let config = InstanceReuseConfig::single_use_with_request_deadline(timeout);
475
476        assert!(matches!(config.max_instance_reuse_count, Range::Value(1)));
477        assert!(matches!(
478            config.max_instance_concurrent_reuse_count,
479            Range::Value(1)
480        ));
481        assert!(matches!(config.request_timeout, Some(Range::Value(value)) if value == timeout));
482        assert_eq!(config.request_deadline, Some(timeout));
483    }
484}