Adding a Frontend

Here, you'll add a web frontend to the code from the previous section.

Creating a web frontend has two parts:

  1. Altering the process code to serve and handle HTTP requests
  2. Writing a webpage to interact with the process. Here, you'll use React to make a single-page app that displays your current games and allows us to: create new games, resign from games, and make moves on the chess board.

JavaScript and React development aren't in the scope of this tutorial, so you can find that code here.

The important part of the frontend for the purpose of this tutorial is how to set up those pre-existing files to be built and installed by kit. When files found in the ui/ directory, if a package.json file is found with a build:copy field in scripts, kit will run that to build the UI (see here). The build:copy in that file builds the UI and then places the resulting files into the pkg/ui/ directory where they will be installed by kit start-package. This allows your process to fetch them from the virtual filesystem, as all files in pkg/ are mounted. See the VFS API overview to see how to use files mounted in pkg/. Additional UI dev info can be found here.

Get the chess UI files and place them in the proper place (next to pkg/):

git clone https://github.com/kinode-dao/chess-ui ui

Chess will use the http_server runtime module to serve a static frontend and receive HTTP requests from it. You'll also use a WebSocket connection to send updates to the frontend when the game state changes.

In my_chess/src/lib.rs, inside init():

#![allow(unused)]
fn main() {
...
use kinode_process_lib::http;
...
// Serve the index.html and other UI files found in pkg/ui at the root path.
http::serve_ui(&our, "ui", true, false, vec!["/"]).unwrap();

// Allow HTTP requests to be made to /games; they will be handled dynamically.
http::bind_http_path("/games", true, false).unwrap();

// Allow websockets to be opened at / (our process ID will be prepended).
http::bind_ws_path("/", true, false).unwrap();
...
}

The above code should be inserted into the init() function such that the frontend is served when the process starts.

The http library in process_lib provides a simple interface for serving static files and handling HTTP requests. Use serve_ui to serve the static files includeded in the process binary, and bind_http_path to handle requests to /games. serve_ui takes five arguments: the process' &Address, the name of the folder inside pkg that contains the index.html and other associated UI files, whether the UI requires authentication, whether the UI is local-only, and the path(s) on which to serve the UI (usually ["/"]). See process_lib docs for more functions and documentation on their parameters. These requests all serve HTTP that can only be accessed by a logged-in node user (the true parameter for authenticated) and can be accessed remotely (the false parameter for local_only). This API is under active development!

Requests on the /games path will arrive as requests to your process, and you'll have to handle them and respond. The request/response format can be imported from http in process_lib. To do this, add a branch to the main request-handling function that takes requests from http_server:distro:sys.

In my_chess/src/lib.rs, inside handle_request():

#![allow(unused)]
fn main() {
...
    } else if message.source().node == our.node
        && message.source().process == "http_server:distro:sys"
    {
        // receive HTTP requests and websocket connection messages from our server
        match serde_json::from_slice::<http::HttpServerRequest>(message.body())? {
            http::HttpServerRequest::Http(ref incoming) => {
                match handle_http_request(our, state, incoming) {
                    Ok(()) => Ok(()),
                    Err(e) => {
                        println!("error handling http request: {:?}", e);
                        http::send_response(
                            http::StatusCode::SERVICE_UNAVAILABLE,
                            None,
                            "Service Unavailable".to_string().as_bytes().to_vec(),
                        )
                    }
                }
            }
            http::HttpServerRequest::WebSocketOpen { channel_id, .. } => {
                // We know this is authenticated and unencrypted because we only
                // bound one path, the root path. So we know that client
                // frontend opened a websocket and can send updates
                state.clients.insert(channel_id);
                Ok(())
            }
            http::HttpServerRequest::WebSocketClose(channel_id) => {
                // client frontend closed a websocket
                state.clients.remove(&channel_id);
                Ok(())
            }
            http::HttpServerRequest::WebSocketPush { .. } => {
                // client frontend sent a websocket message
                // we don't expect this! we only use websockets to push updates
                Ok(())
            }
        }
    } else {
...
}

This code won't compile yet — you need a new function to handle HTTP requests, and a new state parameter to handle active frontend clients.

Before defining handle_http_request, you need to add a new state parameter in the process state. The state will keep track of all connected clients in a HashSet<u32> and send updates to them when the game state changes. You'll also need to update the save_chess_state and load_chess_state functions to handle this new state.

In my_chess/src/lib.rs:

#![allow(unused)]
fn main() {
...
#[derive(Debug, Serialize, Deserialize)]
struct ChessState {
    pub games: HashMap<String, Game>, // game is by opposing player id
    pub clients: HashSet<u32>,        // doesn't get persisted
}
...
}

clients now holds the channel IDs of all connected clients. It'll be used to send updates over WebSockets to the frontend when the game state changes. But wait! This information shouldn't be persisted because those connections will disappear when the process is killed or the node running this process is turned off. Instead, create another state type for persistence and convert to/from the in-memory one above when you save process state.

In my_chess/src/lib.rs:

#![allow(unused)]
fn main() {
...
use std::collections::{HashMap, HashSet};
...
#[derive(Debug, Serialize, Deserialize)]
struct StoredChessState {
    pub games: HashMap<String, Game>, // game is by opposing player id
}

fn save_chess_state(state: &ChessState) {
    set_state(&bincode::serialize(&state.games).unwrap());
}

fn load_chess_state() -> ChessState {
    match get_typed_state(|bytes| Ok(bincode::deserialize::<HashMap<String, Game>>(bytes)?)) {
        Some(games) => ChessState {
            games,
            clients: HashSet::new(),
        },
        None => ChessState {
            games: HashMap::new(),
            clients: HashSet::new(),
        },
    }
}
...
}

Now, change the handle_http_request function to take incoming HTTP requests and return HTTP responses. This will serve the same purpose as the handle_local_request function from the previous chapter, meaning that the frontend will produce actions and the backend will execute them.

An aside: As a process dev, you should be aware that HTTP resources served in this way can be accessed by other processes running on the same node, regardless of whether the paths are authenticated or not. This can be a security risk: if your app is handling sensitive actions from the frontend, a malicious app could make those API requests instead. You should never expect users to "only install non-malicious apps" — instead, use a secure subdomain to isolate your app's HTTP resources from other processes. See the HTTP Server API for more details.

In my_chess/src/lib.rs:

#![allow(unused)]
fn main() {
...
use kinode_process_lib::get_blob;
...
fn handle_http_request(
    our: &Address,
    state: &mut ChessState,
    http_request: &http::IncomingHttpRequest,
) -> anyhow::Result<()> {
    if http_request.path()? != "/games" {
        return http::send_response(
            http::StatusCode::NOT_FOUND,
            None,
            "Not Found".to_string().as_bytes().to_vec(),
        );
    }
    match http_request.method()?.as_str() {
        // on GET: give the frontend all of our active games
        "GET" => http::send_response(
            http::StatusCode::OK,
            Some(HashMap::from([(
                String::from("Content-Type"),
                String::from("application/json"),
            )])),
            serde_json::to_vec(&state.games)?,
        ),
        // on POST: create a new game
        "POST" => {
            let Some(blob) = get_blob() else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            let blob_json = serde_json::from_slice::<serde_json::Value>(&blob.bytes)?;
            let Some(game_id) = blob_json["id"].as_str() else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            if let Some(game) = state.games.get(game_id)
                && !game.ended
            {
                return http::send_response(http::StatusCode::CONFLICT, None, vec![]);
            };

            let player_white = blob_json["white"]
                .as_str()
                .unwrap_or(our.node.as_str())
                .to_string();
            let player_black = blob_json["black"]
                .as_str()
                .unwrap_or(game_id)
                .to_string();

            // send the other player a new game request
            let Ok(msg) = Request::new()
                .target((game_id, our.process.clone()))
                .body(serde_json::to_vec(&ChessRequest::NewGame {
                    white: player_white.clone(),
                    black: player_black.clone(),
                })?)
                .send_and_await_response(5)? else {
                    return Err(anyhow::anyhow!("other player did not respond properly to new game request"))
                };
            // if they accept, create a new game
            // otherwise, should surface error to FE...
            if serde_json::from_slice::<ChessResponse>(msg.body())? != ChessResponse::NewGameAccepted
            {
                return Err(anyhow::anyhow!("other player rejected new game request"));
            }
            // create a new game
            let game = Game {
                id: game_id.to_string(),
                turns: 0,
                board: Board::start_pos().fen(),
                white: player_white,
                black: player_black,
                ended: false,
            };
            let body = serde_json::to_vec(&game)?;
            state.games.insert(game_id.to_string(), game);
            save_chess_state(&state);
            http::send_response(
                http::StatusCode::OK,
                Some(HashMap::from([(
                    String::from("Content-Type"),
                    String::from("application/json"),
                )])),
                body,
            )
        }
        // on PUT: make a move
        "PUT" => {
            let Some(blob) = get_blob() else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            let blob_json = serde_json::from_slice::<serde_json::Value>(&blob.bytes)?;
            let Some(game_id) = blob_json["id"].as_str() else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            let Some(game) = state.games.get_mut(game_id) else {
                return http::send_response(http::StatusCode::NOT_FOUND, None, vec![]);
            };
            if (game.turns % 2 == 0 && game.white != our.node)
                || (game.turns % 2 == 1 && game.black != our.node)
            {
                return http::send_response(http::StatusCode::FORBIDDEN, None, vec![]);
            } else if game.ended {
                return http::send_response(http::StatusCode::CONFLICT, None, vec![]);
            }
            let Some(move_str) = blob_json["move"].as_str() else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            let mut board = Board::from_fen(&game.board).unwrap();
            if !board.apply_uci_move(move_str) {
                // reader note: can surface illegal move to player or something here
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            }
            // send the move to the other player
            // check if the game is over
            // if so, update the records
            let Ok(msg) = Request::new()
                .target((game_id, our.process.clone()))
                .body(serde_json::to_vec(&ChessRequest::Move {
                    game_id: game_id.to_string(),
                    move_str: move_str.to_string(),
                })?)
                .send_and_await_response(5)? else {
                    return Err(anyhow::anyhow!("other player did not respond properly to our move"))
                };
            if serde_json::from_slice::<ChessResponse>(msg.body())? != ChessResponse::MoveAccepted {
                return Err(anyhow::anyhow!("other player rejected our move"));
            }
            // update the game
            game.turns += 1;
            if board.checkmate() || board.stalemate() {
                game.ended = true;
            }
            game.board = board.fen();
            // update state and return to FE
            let body = serde_json::to_vec(&game)?;
            save_chess_state(&state);
            // return the game
            http::send_response(
                http::StatusCode::OK,
                Some(HashMap::from([(
                    String::from("Content-Type"),
                    String::from("application/json"),
                )])),
                body,
            )
        }
        // on DELETE: end the game
        "DELETE" => {
            let Some(game_id) = http_request.query_params.get("id") else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            let Some(game) = state.games.get_mut(game_id) else {
                return http::send_response(http::StatusCode::BAD_REQUEST, None, vec![]);
            };
            // send the other player an end game request
            Request::new()
                .target((game_id.as_str(), our.process.clone()))
                .body(serde_json::to_vec(&ChessRequest::Resign(our.node.clone()))?)
                .send()?;
            game.ended = true;
            let body = serde_json::to_vec(&game)?;
            save_chess_state(&state);
            http::send_response(
                http::StatusCode::OK,
                Some(HashMap::from([(
                    String::from("Content-Type"),
                    String::from("application/json"),
                )])),
                body,
            )
        }
        // Any other method will be rejected.
        _ => http::send_response(http::StatusCode::METHOD_NOT_ALLOWED, None, vec![]),
    }
}
...
}

This is a lot of code. Mostly, it just handles the different HTTP methods and returns the appropriate responses. The only unfamiliar code here is the get_blob() function, which is used here to inspect the HTTP body. See the HTTP API docs (client, server) for more details.

Are you ready to play chess? Almost there! One more missing piece: the backend needs to send WebSocket updates to the frontend after each move in order to update the board without a refresh. Since open channels are already tracked in process state, you just need to send a push to each open channel when a move occurs.

In my_chess/src/lib.rs, add a helper function:

#![allow(unused)]
fn main() {
...
use kinode_process_lib::LazyLoadBlob;
...
fn send_ws_update(
    our: &Address,
    game: &Game,
    open_channels: &HashSet<u32>,
) -> anyhow::Result<()> {
    for channel in open_channels {
        Request::new()
            .target((&our.node, "http_server", "distro", "sys"))
            .body(serde_json::to_vec(
                &http::HttpServerAction::WebSocketPush {
                    channel_id: *channel,
                    message_type: http::WsMessageType::Binary,
                },
            )?)
            .blob(LazyLoadBlob {
                mime: Some("application/json".to_string()),
                bytes: serde_json::json!({
                    "kind": "game_update",
                    "data": game,
                }).to_string().into_bytes(),
            })
            .send()?;
    }
    Ok(())
}
}

Now, anywhere you receive an action from another node (in handle_chess_request(), for example), call send_ws_update(&our, &game, &state.clients)? to send an update to all connected clients. You'll need to add our as a parameter to the handler function. A good place to do this is right before saving the updated state. Local moves from the frontend will update on their own.

Finally, add requests for http_server and vfs messaging capabilities to the manifest.json:

...
"request_capabilities": [
    "http_server:distro:sys",
    "vfs:distro:sys"
],
...

Continue to Putting Everything Together to see the full code and screenshots of the app in action.

Get Help: