Messaging with More Complex Data Types
In this section, you will upgrade your app so that it can handle messages with more elaborate data types such as enum
s and struct
s.
Additionally, you will learn how to handle processes completing or crashing.
(De)Serialization With Serde
In the last section, you created a simple request-response pattern that uses strings as a body
field type.
This is fine for certain limited cases, but in practice, most Kinode processes written in Rust use a body
type that is serialized and deserialized to bytes using Serde.
There are a multitude of libraries that implement Serde's Serialize
and Deserialize
traits, and the process developer is responsible for selecting a strategy that is appropriate for their use case.
Some popular options are bincode
, rmp_serde
(MessagePack), and serde_json
.
In this section, you will use serde_json
to serialize your Rust structs to a byte vector of JSON.
Defining the body
Type
Our old request looked like this:
#![allow(unused)] fn main() { Request::to(&our) .body(b"hello world") .expects_response(5) .send() .unwrap(); }
What if you want to have two kinds of messages, which your process can handle differently?
You need a type that implements the serde::Serialize
and serde::Deserialize
traits, and use that as your body
type.
You can define your types in Rust, but then:
- Processes in other languages will then have to rewrite your types.
- Importing types is haphazard and on a per-package basis.
- Every package might place the types in a different place.
Instead, use the WIT language to define your API, discussed further here. Briefly, WIT is a language-independent way to define types and functions for Wasm components like Kinode processes. Kinode packages can define their API using a WIT file. That WIT file is used to generate code in the given language during compile-time. Kinode also defines a conventional place for these WIT APIs and provides infrastructure for viewing and importing the APIs of other packages.
interface mfa-data-demo {
variant request {
hello(string),
goodbye,
}
variant response {
hello(string),
goodbye,
}
}
world mfa-data-demo-template-dot-os-v0 {
import mfa-data-demo;
include process-v0;
}
The wit_bindgen::generate!()
macro changes slightly, since the world
is now as defined in the API:
#![allow(unused)] fn main() { 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], }); }
which generates the types defined in the WIT API:
#![allow(unused)] fn main() { use crate::kinode::process::mfa_data_demo::{Request as MfaRequest, Response as MfaResponse}; }
It further adds the derives for serde
so that these types can be used smoothly.
Now, when you form Requests and Responses, instead of putting a bytes-string in the body
field, you can use the MfaRequest
/MfaResponse
type.
This comes with a number of benefits:
- You can now use the
body
field to send arbitrary data, not just strings. - Other programmers can look at your code and see what kinds of messages this process might send to their code.
- Other programmers can see what kinds of messages you expect to receive.
- By using an
enum
(WITvariant
s become Rustenum
s), you can exhaustively handle all possible message types, and handle unexpected messages with a default case or an error.
Defining body
types is just one step towards writing interoperable code.
It's also critical to document the overall structure of the program along with message blob
s and metadata
used, if any.
Writing interoperable code is necessary for enabling permissionless composability, and Kinode aims to make this the default kind of program, unlike the centralized web.
Handling Messages
In this example, you will learn how to handle a Request.
So, create a request that uses the new body
type:
#![allow(unused)] fn main() { Request::to(&our) .body(MfaRequest::Hello("hello world".to_string())) .expects_response(5) .send() .unwrap(); }
Next, change the way you handle a message in your process to use your new body
type.
Break out the logic to handle a message into its own function, handle_message()
.
handle_message()
should branch on whether the message is a Request or Response.
Then, attempt to parse every message into the MfaRequest
/MfaResponse
, enum
as appropriate, handle the two cases, and handle any message that doesn't comport to the type.
#![allow(unused)] fn main() { fn handle_message(message: &Message) -> anyhow::Result<bool> { if message.is_request() { match message.body().try_into()? { 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); } } } else { match message.body().try_into()? { MfaResponse::Hello(text) => println!("got a Hello response: {text}"), MfaResponse::Goodbye => println!("got a Goodbye response"), } } Ok(false) } }
Granting Capabilities
Finally, edit your pkg/manifest.json
to grant the terminal process permission to send messages to this process.
That way, you can use the terminal to send Hello
and Goodbye
messages.
Go into the manifest, and under the process name, edit (or add) the grant_capabilities
field like so:
...
"grant_capabilities": [
"terminal:terminal:sys",
"tester:tester:sys"
],
...
Build and Run the Code!
After all this, your code should look like:
#![allow(unused)] fn main() { use crate::kinode::process::mfa_data_demo::{Request as MfaRequest, Response as MfaResponse}; use kinode_process_lib::{await_message, call_init, 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], }); fn handle_message(message: &Message) -> anyhow::Result<bool> { if message.is_request() { match message.body().try_into()? { 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); } } } else { match message.body().try_into()? { MfaResponse::Hello(text) => println!("got a Hello response: {text}"), MfaResponse::Goodbye => println!("got a Goodbye response"), } } Ok(false) } call_init!(init); fn init(our: Address) { println!("begin"); 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(message) { Err(e) => println!("got error while handling message: {e:?}"), Ok(should_exit) => { if should_exit { return; } } }, } } } }
You should be able to build and start your package, then see that initial Hello
message.
At this point, you can use the terminal to test your message types!
You can find the full code here.
First, try sending a Hello
using the m
terminal script.
Get the address of your process by looking at the "started" printout that came from it in the terminal.
As a reminder, these values (<your_process>
, <your_package>
, <your_publisher>
) can be found in the metadata.json
and manifest.json
package files.
m our@<your-process>:<your-package>:<your-publisher> '{"Hello": "hey there"}'
You should see the message text printed.
To grab and print the Response, append a -a 5
to the terminal command:
m our@<your-process>:<your-package>:<your-publisher> '{"Hello": "hey there"}' -a 5
Next, try a goodbye. This will cause the process to exit.
m our@<your-process>:<your-package>:<your-publisher> '"Goodbye"'
If you try to send another Hello
now, nothing will happen, because the process has exited (assuming you have set on_exit: "None"
; with on_exit: "Restart"
it will immediately start up again).
Nice!
You can use kit start-package
to try again.
Aside: on_exit
As mentioned in the previous section, one of the fields in the manifest.json
is on_exit
.
When the process exits, it does one of:
on_exit Setting | Behavior When Process Exits |
---|---|
"None" | Do nothing |
"Restart" | Restart the process |
JSON object | Send the requests described by the JSON object |
A process intended to do something once and exit should have "None"
or a JSON object on_exit
.
If it has "Restart"
, it will repeat in an infinite loop.
A process intended to run over a period of time and serve requests and responses will often have "Restart"
on_exit
so that, in case of crash, it will start again.
Alternatively, a JSON object on_exit
can be used to inform another process of its untimely demise.
In this way, Kinode processes become quite similar to Erlang processes in that crashing can be designed into your process to increase reliability.