Frontend Time

After the last section, you should have a simple process that responds to two commands from the terminal. In this section, you'll add some basic HTTP logic to serve a frontend and accept an HTTP PUT request that contains a command.

If you're the type of person that prefers to learn by looking at a complete example, check out the chess frontend section for a real example application and a link to some frontend code.

Adding HTTP request handling

Using the built-in HTTP server will require handling a new type of Request in our main loop, and serving a Response to it. The process_lib contains types and functions for doing so.

At the top of your process, import get_blob, homepage, and http from kinode_process_lib along with the rest of the imports. You'll use get_blob() to grab the body bytes of an incoming HTTP request.

#![allow(unused)]
fn main() {
use kinode_process_lib::{
    await_message, call_init, get_blob, homepage, http, println, Address, Message, Request,
    Response,
};
}

Keep the custom WIT-defined MfaRequest the same, and keep using that for terminal input.

At the beginning of the init() function, in order to receive HTTP requests, use the kinode_process_lib::http library to bind a new path. Binding a path will cause the process to receive all HTTP requests that match that path. You can also bind static content to a path using another function in the library.

#![allow(unused)]
fn main() {
...
fn init(our: Address) {
    println!("begin");

    let server_config = http::server::HttpBindingConfig::default().authenticated(false);
    let mut server = http::server::HttpServer::new(5);
    server.bind_http_path("/", server_config.authenticated(false)).unwrap();
...
}

http::HttpServer::bind_http_path("/", server_config) arguments mean the following:

  1. The first argument is the path to bind. Note that requests will be namespaced under the process name, so this will be accessible at e.g. /my-process-name/.
  2. The second argument configures the binding. A default setting here serves the page only to the owner of the node, suitable for private app access. Here, setting authenticated(false) serves the page to anyone with the URL.

To handle different kinds of Requests (or Responses), wrap them in a meta Req or Res:

#![allow(unused)]
fn main() {
#[derive(Debug, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto)]
#[serde(untagged)] // untagged as a meta-type for all incoming responses
enum Req {
    MfaRequest(MfaRequest),
    HttpRequest(http::server::HttpServerRequest),
}
}

and match on it in the top-level handle_message():

#![allow(unused)]
fn main() {
fn handle_message(our: &Address, message: &Message) -> Result<bool> {
    if message.is_request() {
        match message.body().try_into()? {
            Req::MfaRequest(ref mfa_request) => {
                return Ok(handle_mfa_request(mfa_request)?);
            }
            Req::HttpRequest(http_request) => {
                handle_http_request(our, http_request)?;
            }
        }
    } else {
        handle_mfa_response(message.body().try_into()?)?;
    }
    Ok(false)
}
}

Here, the logic that was previously in handle_message() is now factored out into handle_mfa_request() and handle_mfa_response():

#![allow(unused)]
fn main() {
fn handle_mfa_request(request: &MfaRequest) -> Result<bool> {
    match request {
        MfaRequest::Hello(text) => {
            println!("got a Hello: {text}");
            Response::new()
                .body(MfaResponse::Hello("hello to you too!".to_string()))
                .send()?
        }
        MfaRequest::Goodbye => {
            println!("goodbye!");
            Response::new().body(MfaResponse::Goodbye).send()?;
            return Ok(true);
        }
    }
    Ok(false)
}

...

fn handle_mfa_response(response: MfaResponse) -> Result<()> {
    match response {
        MfaResponse::Hello(text) => println!("got a Hello response: {text}"),
        MfaResponse::Goodbye => println!("got a Goodbye response"),
    }
    Ok(())
}
}

As a side-note, different apps will want to discriminate between incoming messages differently. For example, to restrict what senders are accepted (say to your own node or to some set of allowed nodes), your process can branch on the source().node.

Handling an HTTP Message

Finally, define handle_http_message().

#![allow(unused)]
fn main() {
fn handle_http_request(our: &Address, request: http::server::HttpServerRequest) -> Result<()> {
    let Some(http_request) = request.request() else {
        return Err(anyhow!("received a WebSocket message, skipping"));
    };
    if http_request.method().unwrap() != http::Method::PUT {
        return Err(anyhow!("received a non-PUT HTTP request, skipping"));
    }
    let Some(body) = get_blob() else {
        return Err(anyhow!(
            "received a PUT HTTP request with no body, skipping"
        ));
    };
    http::server::send_response(http::StatusCode::OK, None, vec![]);
    Request::to(our).body(body.bytes).send().unwrap();
    Ok(())
}
}

Walking through the code, first, you must parse out the HTTP request from the HttpServerRequest. This is necessary because the HttpServerRequest enum contains both HTTP protocol requests and requests related to WebSockets. If your application only needs to handle one type of request (e.g., only HTTP requests), you could simplify the code by directly handling that type without having to check for a specific request type from the HttpServerRequest enum each time. This example is overly thorough for demonstration purposes.

#![allow(unused)]
fn main() {
    let Some(http_request) = request.request() else {
        return Err(anyhow!("received a WebSocket message, skipping"));
    };
}

Next, check the HTTP method in order to only handle PUT requests:

#![allow(unused)]
fn main() {
    if http_request.method().unwrap() != http::Method::PUT {
        return Err(anyhow!("received a non-PUT HTTP request, skipping"));
    }
}

Finally, grab the blob from the request, send a 200 OK response to the client, and handle the blob by sending a Request to ourselves with the blob as the body.

#![allow(unused)]
fn main() {
    let Some(body) = get_blob() else {
        return Err(anyhow!(
            "received a PUT HTTP request with no body, skipping"
        ));
    };
    http::server::send_response(http::StatusCode::OK, None, vec![]);
    Request::to(our).body(body.bytes).send().unwrap();
}

This could be done in a different way, but this simple pattern is useful for letting HTTP requests masquerade as in-Kinode requests.

Putting it all together, you get a process that you can build and start, then use cURL to send Hello and Goodbye requests via HTTP PUTs!

Requesting Capabilities

Also, remember to request the capability to message http-server in manifest.json:

...
"request_capabilities": [
    "http-server:distro:sys"
],
...

The Full Code

#![allow(unused)]
fn main() {
use anyhow::{anyhow, Result};

use crate::kinode::process::mfa_data_demo::{Request as MfaRequest, Response as MfaResponse};
use kinode_process_lib::{
    await_message, call_init, get_blob, homepage, http, println, Address, Message, Request,
    Response,
};

wit_bindgen::generate!({
    path: "target/wit",
    world: "mfa-data-demo-template-dot-os-v0",
    generate_unused_types: true,
    additional_derives: [serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto],
});

// base64-encoded bytes prepended with image type like `data:image/png;base64,`, e.g.
// echo "data:image/png;base64,$(base64 < gosling.png)" | tr -d '\n' > icon
const ICON: &str = include_str!("./icon");

// you can embed an external URL
// const WIDGET: &str = "<iframe src='https://example.com'></iframe>";
// or you can embed your own HTML
const WIDGET: &str = "<html><body><h1>Hello, Kinode!</h1></body></html>";

#[derive(Debug, serde::Deserialize, serde::Serialize, process_macros::SerdeJsonInto)]
#[serde(untagged)] // untagged as a meta-type for all incoming responses
enum Req {
    MfaRequest(MfaRequest),
    HttpRequest(http::server::HttpServerRequest),
}

fn handle_mfa_request(request: &MfaRequest) -> Result<bool> {
    match request {
        MfaRequest::Hello(text) => {
            println!("got a Hello: {text}");
            Response::new()
                .body(MfaResponse::Hello("hello to you too!".to_string()))
                .send()?
        }
        MfaRequest::Goodbye => {
            println!("goodbye!");
            Response::new().body(MfaResponse::Goodbye).send()?;
            return Ok(true);
        }
    }
    Ok(false)
}

fn handle_http_request(our: &Address, request: http::server::HttpServerRequest) -> Result<()> {
    let Some(http_request) = request.request() else {
        return Err(anyhow!("received a WebSocket message, skipping"));
    };
    if http_request.method().unwrap() != http::Method::PUT {
        return Err(anyhow!("received a non-PUT HTTP request, skipping"));
    }
    let Some(body) = get_blob() else {
        return Err(anyhow!(
            "received a PUT HTTP request with no body, skipping"
        ));
    };
    http::server::send_response(http::StatusCode::OK, None, vec![]);
    Request::to(our).body(body.bytes).send().unwrap();
    Ok(())
}

fn handle_mfa_response(response: MfaResponse) -> Result<()> {
    match response {
        MfaResponse::Hello(text) => println!("got a Hello response: {text}"),
        MfaResponse::Goodbye => println!("got a Goodbye response"),
    }
    Ok(())
}

fn handle_message(our: &Address, message: &Message) -> Result<bool> {
    if message.is_request() {
        match message.body().try_into()? {
            Req::MfaRequest(ref mfa_request) => {
                return Ok(handle_mfa_request(mfa_request)?);
            }
            Req::HttpRequest(http_request) => {
                handle_http_request(our, http_request)?;
            }
        }
    } else {
        handle_mfa_response(message.body().try_into()?)?;
    }
    Ok(false)
}

call_init!(init);
fn init(our: Address) {
    println!("begin");

    let server_config = http::server::HttpBindingConfig::default().authenticated(false);
    let mut server = http::server::HttpServer::new(5);
    server.bind_http_path("/api", server_config).unwrap();
    server
        .serve_file(
            &our,
            "ui/index.html",
            vec!["/"],
            http::server::HttpBindingConfig::default(),
        )
        .unwrap();
    homepage::add_to_homepage("My First App", Some(ICON), Some("/"), Some(WIDGET));

    Request::to(&our)
        .body(MfaRequest::Hello("hello world".to_string()))
        .expects_response(5)
        .send()
        .unwrap();

    loop {
        match await_message() {
            Err(send_error) => println!("got SendError: {send_error}"),
            Ok(ref message) => match handle_message(&our, message) {
                Err(e) => println!("got error while handling message: {e:?}"),
                Ok(should_exit) => {
                    if should_exit {
                        return;
                    }
                }
            },
        }
    }
}
}

Use the following cURL command to send a Hello Request Make sure to replace the URL with your node's local port and the correct process name. Note: if you had set authenticated to true in bind_http_path(), you would need to add an Authorization header to this request with the JWT cookie of your node. This is saved in your browser automatically on login.

curl -X PUT -d '{"Hello": "greetings"}' http://localhost:8080/mfa_fe_demo:mfa_fe_demo:template.os/api

You can find the full code here.

There are a few lines we haven't covered yet: learn more about serving a static frontend and adding a homepage icon and widget below.

Serving a static frontend

If you just want to serve an API, you've seen enough now to handle PUTs and GETs to your heart's content. But the classic personal node app also serves a webpage that provides a user interface for your program.

You could add handling to root / path to dynamically serve some HTML on every GET. But for maximum ease and efficiency, use the static bind command on / and move the PUT handling to /api. To do this, edit the bind commands in my_init_fn to look like this:

#![allow(unused)]
fn main() {
    let mut server = http::server::HttpServer::new(5);
    server.bind_http_path("/api", server_config).unwrap();
    server
        .serve_file(
            &our,
            "ui/index.html",
            vec!["/"],
            http::server::HttpBindingConfig::default(),
        )
        .unwrap();
}

Here you are setting authenticated to false in the bind_http_path() call, but to true in the serve_file call. This means the API is public; if instead you want the webpage to be served exclusively by the browser, change authenticated to true in bind_http_path() as well.

You must also add a static index.html file to the package. UI files are stored in the ui/ directory and built into the application by kit build automatically. Create a ui/ directory in the package root, and then a new file in ui/index.html with the following contents. Make sure to replace the fetch URL with your process ID!

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <main>
        <h1>This is a website!</h1>
        <p>Enter a message to send to the process:</p>
        <form id="hello-form" class="col">
        <input id="hello" required="" name="hello" placeholder="hello world" value="">
        <button> PUT </button>
      </form>
    </main>
    <script>
        async function say_hello(text) {
          const result = await fetch("/mfa-fe-demo:mfa-fe-demo:template.os/api", {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ "Hello": text }),
          });
          console.log(result);
        }


        document.addEventListener("DOMContentLoaded", () => {
          const form = document.getElementById("hello-form");
          form.addEventListener("submit", (e) => {
            e.preventDefault();
            e.stopPropagation();
            const text = document.getElementById("hello").value;
            say_hello(text);
          });
        });
    </script>
  </body>
</html>

This is a super barebones index.html that provides a form to make requests to the /api endpoint. Additional UI dev info can be found here.

Next, add two more entries to manifest.json: messaging capabilities to the VFS which is required to store and access the UI index.html, and the homepage capability which is required to add our app to the user's homepage (next section):

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

After saving ui/index.html, rebuilding the program, and starting the package again with kit bs, you should be able to navigate to your http://localhost:8080/mfa_fe_demo:mfa_fe_demo:template.os and see the form page. Because you now set authenticated to true in the /api binding, the webpage will still work, but cURL will not.

The user will navigate to / to see the webpage, and when they make a PUT request, it will automatically happen on /api to send a message to the process.

This frontend is now fully packaged with the process — there are no more steps! Of course, this can be made arbitrarily complex with various frontend frameworks that produce a static build.

In the next and final section, learn about the package metadata and how to share this app across the Kinode network.

Adding a Homepage Icon and Widget

In this section, you will learn how to customize your app icon with a clickable link to your frontend, and how to create a widget to display on the homepage.

Adding the App to the Home Page

Encoding an Icon

Choosing an emblem is a difficult task. You may elect to use your own, or use this one:

gosling

On the command line, encode your image as base64, and prepend data:image/png;base64,:

echo "data:image/png;base64,$(base64 < gosling.png)" | tr -d '\n' > icon

Then, move icon next to lib.rs in your app's src/ directory. Finally, include the icon data in your lib.rs file just after the imports:

#![allow(unused)]
fn main() {
const ICON: &str = include_str!("./icon");
}

Clicking the Button

The Kinode process lib exposes an add_to_homepage() function that you can use to add your app to the homepage.

In your init(), add the following line: This line in the init() function adds your process, with the given icon, to the homepage:

#![allow(unused)]
fn main() {
    server.bind_http_path("/api", server_config).unwrap();
}

Writing a Widget

A widget is an HTML iframe. Kinode apps can send widgets to the homepage process, which will display them on the user's homepage. They are quite simple to configure. In add_to_homepage(), the final field optionally sets the widget:

#![allow(unused)]
fn main() {
    server.bind_http_path("/api", server_config).unwrap();
}

which uses the WIDGET constant, here:

#![allow(unused)]
fn main() {
// you can embed an external URL
// const WIDGET: &str = "<iframe src='https://example.com'></iframe>";
// or you can embed your own HTML
const WIDGET: &str = "<html><body><h1>Hello, Kinode!</h1></body></html>";
}

After another kit bs, you should be able to reload your homepage and see your app icon under "All Apps", as well as your new widget. To dock your app, click the heart icon on it. Click the icon itself to go to the UI served by your app.

For an example of a more complex widget, see the source code of our app store widget, below.

Widget Case Study: App Store

The app store's widget makes a single request to the node, to determine the apps that are listed in the app store. It then creates some HTML to display the apps in a nice little list.

<html>
<head>
    <script src="https://cdn.tailwindcss.com"></script>
    <style>
        .app {
            width: 100%;
        }

        .app-image {
            background-size: cover;
            background-repeat: no-repeat;
            background-position: center;
        }

        .app-info {
            max-width: 67%
        }

        @media screen and (min-width: 500px) {
            .app {
                width: 49%;
            }
        }
    </style>
</head>
<body class="text-white overflow-hidden">
    <div
        id="latest-apps"
        class="flex flex-wrap p-2 gap-2 items-center backdrop-brightness-125 rounded-xl shadow-lg h-screen w-screen overflow-y-auto"
        style="
            scrollbar-color: transparent transparent;
            scrollbar-width: none;
        "
    >
    </div>
    <script>
        document.addEventListener('DOMContentLoaded', function() {
            fetch('/main:app_store:sys/apps/listed', { credentials: 'include' })
                .then(response => response.json())
                .then(data => {
                    const container = document.getElementById('latest-apps');
                    data.forEach(app => {
                        if (app.metadata) {
                            const a = document.createElement('a');
                            a.className = 'app p-2 grow flex items-stretch rounded-lg shadow bg-white/10 hover:bg-white/20 font-sans cursor-pointer';
                            a.href = `/main:app_store:sys/app-details/${app.package}:${app.publisher}`
                            a.target = '_blank';
                            a.rel = 'noopener noreferrer';
                            const iconLetter = app.metadata_hash.replace('0x', '')[0].toUpperCase();
                            a.innerHTML = `<div
                                class="app-image rounded mr-2 grow"
                                style="
                                    background-image: url('${app.metadata.image || `/icons/${iconLetter}`}');
                                    height: 92px;
                                    width: 92px;
                                    max-width: 33%;
                                "
                            ></div>
                            <div class="app-info flex flex-col grow">
                                <h2 class="font-bold">${app.metadata.name}</h2>
                                <p>${app.metadata.description}</p>
                            </div>`;
                                container.appendChild(a);
                        }
                    });
                })
                .catch(error => console.error('Error fetching apps:', error));
        });
    </script>
</body>
</html>
Get Help: