picoserve

repository·development·Indexed 18 days ago

https://github.com/sammhicks/picoserve

An async no_std HTTP server designed for bare-metal and embedded environments, such as the Raspberry Pi Pico W. Featuring an API inspired by axum, picoserve avoids heap usage and supports JSON responses, Server-Sent Events (SSE), and WebSockets. It provides a flexible routing system with extractors for state, query parameters, forms, and JSON bodies, and is compatible with both embassy and tokio runtimes.

Tokens
12.8K
Snippets
29
Records
47
Agent score
63%

What's inside picoserve

  1. Overview of picoserve

    development

    picoserve is an async no_std HTTP server designed for bare-metal environments. It is heavily inspired by axum and is optimized for embedded runtimes like embassy, specifically targeting hardware like the Raspberry Pi Pico W.

    Key Features

    • No heap usage: Designed for memory-constrained environments.
    • Axum-like Handlers: Handler functions are async functions that accept zero or more extractors as arguments and return a type implementing IntoResponse.
    • Data Parsing: Supports Query and Form parsing using serde.
    • Response Types: Supports JSON responses, Server-Sent Events (SSE), and Web Sockets.
    • Automatic HEAD handling: The HEAD method is automatically handled.

    Important Limitations & Security

    • Breaking Changes: The project is in 0.*.* versioning; API changes may occur.
    • Input Limits: URL-Encoded strings (in Query and Form parsing) have a maximum length of 1024.
    • Security Warning: The server has not undergone extensive stress-testing. It is advised to place it behind a proxy (like nginx) rather than exposing it directly to the internet.
    • Serialization Consistency: For large payloads, certain serialization methods (like DebugValue or JSON) might be called multiple times. You MUST ensure that the output of serialization remains identical during repeated calls with the same value.
    • Content-Length: The framework does not verify that the actual body length matches the value provided in the Content-Length header.
  2. Obtain WiFi firmware for CYW43

    development

    The WiFi firmware used in the examples/embassy/cyw43-firmware directory is sourced from the Infineon WiFi Host Driver repository. If you need to verify the source or obtain the original files, they are located in the COMPONENT_43439 directory of the Infineon repository.

    Source URL: https://github.com/Infineon/wifi-host-driver/tree/master/WiFi_Host_Driver/resources/firmware/COMPONENT_43439

  3. Embassy on Raspberry Pi Pico examples

    development

    Picoserve provides several examples specifically tailored for the embassy runtime, which is useful for embedded development on hardware like the Raspberry Pi Pico.

    Key patterns demonstrated include:

    • Minimal Setup: Using Router for basic request handling (hello_world, hello_world_defmt).
    • Hardware Control: Interacting with hardware (e.g., set_led to control the Pico W LED via web interface).
    • Application State: Passing data during App construction (app_with_props) and managing various application states (various_states).
    • Graceful Shutdown: Implementing shutdown using either an array of Futures (graceful_shutdown_using_future_array) or embassy_executor tasks (graceful_shutdown_using_tasks).
    • Advanced Protocols: Implementing WebSockets (web_sockets) and handling large requests by extending read timeouts (huge_requests).
  4. Tokio runtime examples

    development

    For standard asynchronous environments using the tokio runtime, picoserve offers a wide range of usage patterns:

    Routing and Request Handling

    • Basic Routing: Setting up a Router (hello_world) or running on a single thread (hello_world_single_thread).
    • Advanced Routing: Nesting Routers (nested_router), runtime selection of routers (conditional_routing), and providing a fallback PathRouterService (routing_fallback).
    • Data Extraction: Extracting data from path segments (path_parameters), URL search parameters (query), and implementing custom data extraction from requests (custom_extractor).
    • Methods and Files: Handling GET/POST methods and serving files (form).

    State Management

    • Stateful Applications: General stateful apps (state), state derived from the connection (state_local), and using multiple distinct states within a single Router to support separated nested applications (state_multiple).
    • State-Aware Responses: Returning responses that utilize the application State when writing to the socket (response_using_state).

    Advanced Features and Protocols

    • Streaming and Long-lived Connections: Using Transfer-Encoding: chunked (chunked_response), Server-Sent Events (server_sent_events), and WebSockets (web_sockets).
    • Middleware and Services: Implementing middleware like request-duration logging (layers) or custom MethodHandlerService for reporting request info (request_info).
    • Server Management: Graceful shutdown of the server, including specific handling for SSE (graceful_shutdown_server_sent_events) and WebSocket (graceful_shutdown_web_sockets) connections.
    • Static Content: Serving static assets like HTML and CSS (static_content).
    • Configuration: Extending read timeouts to support large requests (huge_requests).
  5. How middleware layers work in picoserve

    development

    A Layer is a middleware component used to intercept and transform the request-response lifecycle. Layers can be used to:

    • Inspect requests before they reach the inner handler.
    • Transform state: Pass a different NextState to the inner handler than the one provided to the layer.
    • Transform path parameters: Pass different NextPathParameters to the inner handler.
    • Short-circuit: Send a response immediately instead of passing the request to the inner handler.
    • Transform responses: Modify the response returned by the inner handler.

    To modify a response, you must create a struct that implements the ResponseWriter trait, wrap the original response_writer, and pass your wrapper to the next.run() method.

    Implementation Pattern

    To implement a layer, you must define the associated types NextState and NextPathParameters and implement the call_layer method.

    // Conceptual implementation of a Layer
    impl Layer<State, PathParameters> for MyMiddleware {
        type NextState = NewState;
        type NextPathParameters = NewPathParameters;
    
        async fn call_layer<
            'a,
            R: Read + 'a,
            NextLayer: Next<'a, R, Self::NextState, Self::NextPathParameters>,
            W: ResponseWriter<Error = R::Error>,
        >( 
            &self, 
            next: NextLayer, 
            state: &State, 
            path_parameters: PathParameters, 
            request_parts: RequestParts<'_>, 
            response_writer: W 
        ) -> Result<ResponseSent, W::Error> {
            // 1. Inspect or modify request_parts
            // 2. Call next.run() with modified state/params or a wrapped response_writer
            next.run(state, path_parameters, response_writer).await
        }
    }
  6. Implement a `WebSocketCallback` to handle messages

    development

    To process WebSocket traffic, implement the WebSocketCallback trait. The run method provides a SocketRx for reading frames/messages and a SocketTx for writing them.

    If your application requires access to the server's shared state, implement WebSocketCallbackWithState<State> and use upgrade.on_upgrade_using_state(callback) instead.

    If you need to handle graceful server shutdowns, implement WebSocketCallbackWithShutdownSignal to receive a signal when the server is shutting down.

    struct MyWebSocketCallback;
    
    impl WebSocketCallback for MyWebSocketCallback {
        async fn run<R: Read, W: Write<Error = R::Error>>(
            self,
            rx: SocketRx<R>,
            tx: SocketTx<W>,
        ) -> Result<(), W::Error> {
            // Handle connection logic here
            Ok(())
        }
    }
  7. Build an app using AppBuilder traits

    development

    For use cases requiring static routers (often requiring nightly Rust), picoserve provides AppBuilder and AppWithStateBuilder traits. These allow you to define how a Router is constructed, potentially including application state.

    • AppBuilder: Used for routers with no state.
    • AppWithStateBuilder: Used for routers with a declared State type.
    • AppRouter<Props>: A type alias for the resulting router constructed from properties implementing these traits.
  8. How extractors and handler functions work

    development

    In picoserve, a handler function is an asynchronous function that accepts multiple arguments called "extractors".

    Extractors are types that implement specific traits to pull data out of an incoming request:

    1. FromRequestParts: Used for extractors that only need request metadata (headers, method, URI, query strings, etc.) and do not require the request body. Examples include State<T> and Query<T>.
    2. FromRequest: Used for extractors that need to consume or read the request body. Examples include Json<T>, Form<T>, and raw byte slices like &[u8] or String.

    If an extractor fails, it returns a Rejection type, which is automatically converted into an appropriate HTTP response (e.g., a 400 Bad Request).

  9. Implement Graceful Shutdown in picoserve

    development

    To prevent abrupt termination, you can prepare the server to handle a graceful shutdown using .with_graceful_shutdown(). This method takes a shutdown_signal (a future) and a shutdown_timeout. If the signal resolves, the server will attempt to finish ongoing requests within the specified timeout before shutting down.

    // Assuming 'server' is an initialized Server instance
    let server_with_shutdown = server.with_graceful_shutdown(
        my_shutdown_signal_future, 
        Duration::from_secs(5)
    );
    
    // Then call .serve(socket) on the resulting server
  10. Run picoserve using Tokio for testing

    development

    While picoserve is designed for no_std environments, you can use it in a std environment with tokio for testing or development. This involves binding a TcpListener, creating a Router, and using picoserve::Server::new_tokio to serve the stream.

    Note that picoserve::Server::new_tokio requires a buffer for the server to operate, which in this example is a fixed-size array [0; 2048].

    use picoserve::routing::get;
    
    #[tokio::main(flavor = "current_thread")]
    async fn main() -> anyhow::Result<()> {
        let port = 8000;
    
        let app = 
            std::rc::Rc::new(picoserve::Router::new().route("/", get(|| async { "Hello World" })));
    
        let socket = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)).await?;
    
        println!("http://localhost:{port}/");
    
        tokio::task::LocalSet::new()
            .run_until(async {
                loop {
                    let (stream, remote_address) = socket.accept().await?;
    
                    println!("Connection from {remote_address}");
    
                    let app = app.clone();
    
                    tokio::task::spawn_local(async move {
                        static CONFIG: picoserve::Config = 
                            picoserve::Config::const_default().keep_connection_alive();
    
                        match picoserve::Server::new_tokio(&app, &CONFIG, &mut [0; 2048])
                            .serve(stream)
                            .await
                        {
                            Ok(picoserve::DisconnectionInfo {
                                handled_requests_count,
                                ..
                            }) => {
                                println!("{handled_requests_count} requests handled from {remote_address}")
                            }
                            Err(err) => println!("{err:?}"),
                        }
                    });
                }
            })
            .await
    }
  11. Configure the picoserve Server

    development

    Use the Config struct to define server behavior, specifically timeouts and connection persistence. You can create a configuration with custom timeouts or use the default settings.

    Key configuration options:

    • timeouts: A Timeouts struct defining durations for start_read_request, persistent_start_read_request, read_request, and write operations.
    • connection: A KeepAlive enum determining if the connection stays open after a response (KeepAlive::KeepAlive) or closes (KeepAlive::Close).

    By default, picoserve closes the connection after each response. Use .keep_connection_alive() to enable persistent connections.

    use picoserve::{Config, Timeouts, KeepAlive};
    use time::Duration;
    
    // Custom configuration
    let config = Config::new(Timeouts {
        start_read_request: Duration::from_secs(10),
        persistent_start_read_request: Duration::from_secs(2),
        read_request: Duration::from_secs(5),
        write: Duration::from_secs(2),
    }).keep_connection_alive();
    
    // Or use defaults
    let default_config = Config::default();
  12. Use StatusCode to represent HTTP responses

    development

    The StatusCode struct represents an HTTP response status code. You can create a custom status code using StatusCode::new(u16) or use the provided predefined constants for standard HTTP responses.

    StatusCode implements IntoResponse, meaning you can directly write a status code to a connection as a response. When written directly, it will produce a response with the body Error {code} (e.g., Error 404).

    // Using a predefined constant
    let status = StatusCode::OK;
    
    // Creating a custom status code
    let custom_status = StatusCode::new(418);
    
    // Getting the numerical value
    let code_u16 = status.as_u16();