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("ms") {
218                    Duration::from_millis(num.parse().map_err(error)?)
219                } else if let Some(num) = s.strip_suffix("us").or(s.strip_suffix("μs")) {
220                    Duration::from_micros(num.parse().map_err(error)?)
221                } else if let Some(num) = s.strip_suffix("ns") {
222                    Duration::from_nanos(num.parse().map_err(error)?)
223                } else if let Some(num) = s.strip_suffix("s") {
224                    Duration::from_secs(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![
342            spin_app::locked::SERVICE_CHAINING_KEY,
343            spin_app::locked::MIDDLEWARE_KEY,
344        ]
345    }
346
347    fn display_name() -> String {
348        "HTTP".to_string()
349    }
350}
351
352impl HttpTrigger {
353    /// Create a new `HttpTrigger`.
354    pub fn new(
355        app: &spin_app::App,
356        listen_addr: SocketAddr,
357        tls_config: Option<TlsConfig>,
358        find_free_port: bool,
359        http1_max_buf_size: Option<usize>,
360        reuse_config: InstanceReuseConfig,
361        output_format: OutputFormat,
362    ) -> anyhow::Result<Self> {
363        Self::validate_app(app)?;
364
365        Ok(Self {
366            listen_addr,
367            tls_config,
368            find_free_port,
369            http1_max_buf_size,
370            reuse_config,
371            output_format,
372        })
373    }
374
375    /// Turn this [`HttpTrigger`] into an [`HttpServer`].
376    pub fn into_server<F: RuntimeFactors>(
377        self,
378        trigger_app: TriggerApp<F>,
379    ) -> anyhow::Result<Arc<HttpServer<F>>> {
380        let Self {
381            listen_addr,
382            tls_config,
383            find_free_port,
384            http1_max_buf_size,
385            reuse_config,
386            output_format,
387        } = self;
388        let server = Arc::new(HttpServer::new(
389            listen_addr,
390            tls_config,
391            find_free_port,
392            trigger_app,
393            http1_max_buf_size,
394            reuse_config,
395            output_format,
396        )?);
397        Ok(server)
398    }
399
400    fn validate_app(app: &App) -> anyhow::Result<()> {
401        use spin_http::{
402            config::{HttpExecutorType, HttpTriggerConfig},
403            routes::HttpTriggerRouteConfig,
404        };
405
406        #[derive(Deserialize)]
407        #[serde(deny_unknown_fields)]
408        struct TriggerMetadata {
409            base: Option<String>,
410        }
411        if let Some(TriggerMetadata { base: Some(base) }) = app.get_trigger_metadata("http")? {
412            if base == "/" {
413                tracing::warn!(
414                    "This application has the deprecated trigger 'base' set to the default value '/'. This may be an error in the future!"
415                );
416            } else {
417                bail!(
418                    "This application is using the deprecated trigger 'base' field. The base must be prepended to each [[trigger.http]]'s 'route'."
419                )
420            }
421        }
422
423        let mut explain_wagi_deprecation = false;
424        for trigger in app.triggers_with_type("http") {
425            if let Ok(config) = trigger.typed_config::<HttpTriggerConfig>()
426                && let Some(executor) = config.executor
427                && let HttpExecutorType::Wagi(_) = executor
428            {
429                let description = match config.route {
430                    HttpTriggerRouteConfig::Route(r) => format!("route {r}"),
431                    HttpTriggerRouteConfig::Private(_) => format!(
432                        "private endpoint for {}",
433                        config.component.unwrap_or_else(|| "<unknown>".into())
434                    ),
435                };
436                terminal::warn!("HTTP {description} uses the WAGI executor.");
437                explain_wagi_deprecation = true;
438            }
439        }
440        if explain_wagi_deprecation {
441            terminal::warn!("WAGI will be deprecated in a future version of Spin.");
442            eprintln!(
443                "To provide feedback, please visit https://github.com/spinframework/spin/issues/3520.\n"
444            );
445        }
446
447        Ok(())
448    }
449}
450
451fn parse_listen_addr(addr: &str) -> anyhow::Result<SocketAddr> {
452    let addrs: Vec<SocketAddr> = addr.to_socket_addrs()?.collect();
453    // Prefer 127.0.0.1 over e.g. [::1] because CHANGE IS HARD
454    if let Some(addr) = addrs
455        .iter()
456        .find(|addr| addr.is_ipv4() && addr.ip() == Ipv4Addr::LOCALHOST)
457    {
458        return Ok(*addr);
459    }
460    // Otherwise, take the first addr (OS preference)
461    addrs.into_iter().next().context("couldn't resolve address")
462}
463
464#[derive(Debug, PartialEq)]
465enum NotFoundRouteKind {
466    Normal(String),
467    WellKnown,
468}
469
470/// Translate a [`hyper::Error`] to a wasi-http `ErrorCode` in the context of a request.
471pub fn hyper_request_error(err: hyper::Error) -> ErrorCode {
472    // If there's a source, we might be able to extract a wasi-http error from it.
473    if let Some(cause) = err.source()
474        && let Some(err) = cause.downcast_ref::<ErrorCode>()
475    {
476        return err.clone();
477    }
478
479    tracing::warn!("hyper request error: {err:?}");
480
481    ErrorCode::HttpProtocolError
482}
483
484pub fn dns_error(rcode: String, info_code: u16) -> ErrorCode {
485    ErrorCode::DnsError(
486        wasmtime_wasi_http::p2::bindings::http::types::DnsErrorPayload {
487            rcode: Some(rcode),
488            info_code: Some(info_code),
489        },
490    )
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn parse_listen_addr_prefers_ipv4() {
499        let addr = parse_listen_addr("localhost:12345").unwrap();
500        assert_eq!(addr.ip(), Ipv4Addr::LOCALHOST);
501        assert_eq!(addr.port(), 12345);
502    }
503
504    #[test]
505    fn request_deadline_config_is_single_use() {
506        let timeout = Duration::from_millis(500);
507        let config = InstanceReuseConfig::single_use_with_request_deadline(timeout);
508
509        assert!(matches!(config.max_instance_reuse_count, Range::Value(1)));
510        assert!(matches!(
511            config.max_instance_concurrent_reuse_count,
512            Range::Value(1)
513        ));
514        assert!(matches!(config.request_timeout, Some(Range::Value(value)) if value == timeout));
515        assert_eq!(config.request_deadline, Some(timeout));
516    }
517}