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:
- 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/
. - 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:
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>