Build a Decentralised Discussion Forum with EXM
Written by Rohit Pathare
Execution Machine (EXM) is a developer platform that facilitates the creation of immutable, trustless and scalable applications in a frictionless manner with the help of blockchain-based serverless functions on Arweave. The serverless functions off-load the burden of server management and let users focus on development.
Usually, there is a processing lag associated between writing information on blockchains and reading the latest updated version. EXM, however, relies on a unique hybrid system that leverages the advantages of a centralised system by eliminating potential lag and rapidly serving information to applications. It does so, while maintaining the benefits of decentralisation, by creating a copy of the serverless functions and associated data on a cached layer, while the original request is being uploaded to Arweave.
Read more about how EXM works in this article.
Getting Started
In the past, we have deployed and interacted with serverless function with EXM’s SDK. In this article we take this a step further by creating a frontend for other users to interact with.
Here’s the steps we’ll walkthrough:
- Initiate a Svelte repository
- Define logic for a discussion forum
- Deploy the serverless function on Arweave
- Create a frontend to interact with this function
- Upload application frontend on Arweave
The main tools used in this project are as follows:
- Execution Machine SDK: Serverless Function
- Svelte: Frontend
- Axios: Interacting with Serverless Function from Frontend
- Arweave and Bundlr: Uploading Frontend to Arweave
Let’s start by creating a project from svelte’s templates.
npm create vite@latest exm-forum --template svelte
The package manager will as for prompts and we pass in the following options:
Need to install the following packages:
create-vite@4.2.0
Ok to proceed? (y) y
✔ Select a framework: › Svelte
✔ Select a variant: › JavaScript
Once done, we navigate into the project directory in the terminal and opening it up in a code editor:
cd exm-forum
We need to install a few more dependencies by running the following command in the terminal:
yarn add @execution-machine/sdk axios uuid
Finally, we must delete the lib
sub folder from exm-forum/src
. With the final cleanup done, we can start writing the code.
The Serverless Function
There’s two steps involved with the serverless function, namely, defining the logic and deploying the function.
Defining the Logic
We start by creating a utils
folder in the exm-forum/src
and create a file named handler.js
. The function lets users create a discussion topic thread and lets users add comments on the created threads.
// handle.js
export async function handle(state, action) {
// desctructing the input object passed in as data with the write call
const { input } = action;
if (input.type === 'createDiscussion') {
state.discussions[input.discussion.id] = input.discussion;
}
if (input.type === 'addComment') {
state.discussions[input.id].comments.push(input.comment);
}
return { state };
}
The syntax for defining functions is based off of the standard implemented by SmartWeave for smart contracts in JavaScript. Every function has a state
which is a JSON object of values stored in it and actions
to interact with these values.
The state
of our functions will have an object called discussions
where every discussion is a key-value with the following structure:
id : {
id: string,
author: string,
topic: string,
comments: [],
}
The discussion
object consists of an id
, author
, topic
and an array of comments
. The id
is used later on to add comments to a particular discussion thread. Each comment itself is an object consisting of an author
and comment text
.
comment : {
author: string,
text: string,
}
Based on these two data types, there are two actions possible with the function, namely, creating a new discussion or adding a comment to an existing discussion. The frontend sends the action type
and associated inputs to the function to help perform the action successfully.
if (input.type === 'createDiscussion') {
state.discussions[input.discussion.id] = input.discussion;
}
If the action type passed is createDiscussion
, the action creates a new key-value pair with the key as a unique discussion id
and the value being an object consisting details about the discussion.
if (input.type === 'addComment') {
state.discussions[input.id].comments.push(input.comment);
}
If the action type is addComment
, the action adds a comment
object to the comments array for a given discussion id
. This helps associate comments to particular discussion.
Finally, the function returns the updated state
of the function after running the requested action.
Awesome! We just wrote the logic for our serverless function. Now we can deploy it.
Deploying the Function
We shall deploy the serverless function on Arweave with the help of the EXM SDK. Create a file named deploy.js
in the src/utils
sub folder.
// deploy.js
import { Exm, ContractType } from '@execution-machine/sdk';
import fs from 'fs';
const APIKEY = process.env.EXM_PK;
export const exmInstance = new Exm({ token: APIKEY });
const contractSource = fs.readFileSync('./src/utils/handler.js');
const data = await exmInstance.functions.deploy(
contractSource,
{
"discussions": {}
},
ContractType.JS)
console.log({ data });
/* after the contract is deployed, write the function id to a local file */
fs.writeFileSync('./src/utils/functionId.js', `export const functionId = "${data.id}"`);
The deploy process starts off by creating a new EXM instance for which an API key is needed. EXM seeks to be crypto agnostic and hence, it facilitates building on Arweave with the help of a single API key without the need for knowledge of or access to blockchain based technologies like wallets and cryptocurrencies.
It is very easy to get an EXM token from their app with a simple sign-up from your preferred option.
⚠️ The token is an identifier to our account and lets us access functions associated with it. Hence, it is vital to ensure this token is kept secret to prevent any spams and attacks to our functions. The best way to do so is using environment variables.
In the terminal run the following command in the projects root (main) directory:
export EXM_PK=<your_token_here>
After initialising the EXM instance, the code deploys the function:
const contractSource = fs.readFileSync('./src/utils/handler.js');
const data = await exmInstance.functions.deploy(
contractSource,
{
"discussions": {}
},
ContractType.JS)
console.log({ data });
The deploy
method takes in three arguments, the contractSource
which is the logic we defined earlier, the initial state of the function and the programming language used to define the function. We console log the output of this to ensure the successful deployment of the function. The log returns an object with a unique id
for the function. This id
is useful for further interactions such as read and write calls.
fs.writeFileSync('./src/utils/functionId.js', `export const functionId = "${data.id}"`);
Lastly, the deploy file creates a new file named functionId.js
within the src/utils
sub-folder containing the aforementioned unique function id
for future use.
⚠️ The
functionId
must be kept secret in order to prevent spamming and manipulation of the function. Make sure to addsrc/utils/functionId.js
in the.gitignore
file.
The deploy file is essentially a script that must be run in the terminal as follows to execute the code within it:
# use appropriate relative path for file
node src/utils/deploy.js
Wow! We just deployed a serverless function on Arweave.
Now let’s give our users an interface to interact with it.
The Frontend
The frontend will consist of a heading, a form that lets users create a new discussion and every created discussion will be displayed below that allowing users to read comments submitted to a discussion and even add their own comments to the same.
The Main Application
The App.svelte
file located within the src
sub-folder is the main file we will deal with for interacting with the serverless function from the frontend. Even though most of the syntax is explained, checkout the svelte docs if faced with any difficulty.
Replace the existing code with the following:
<script>
import axios from "axios";
import { onMount } from "svelte";
import { functionId } from "./utils/functionId.js";
import { v4 as uuid } from "uuid";
$: state = {
data: {
discussions: {},
},
};
onMount(async () => {
state = await axios({
method: "get",
url: `https://${functionId}.exm.run`,
});
});
async function handleSubmit(e) {
const res = await axios({
method: "post",
url: `https://${functionId}.exm.run`,
data: {
type: "createDiscussion",
discussion: {
id: uuid(),
author: e.target.author.value,
topic: e.target.topic.value,
comments: [],
},
},
});
state.data.discussions = res.data.data.execution.state.discussions;
e.target.reset();
}
async function handleComment(e) {
const res = await axios({
method: "post",
url: `https://${functionId}.exm.run`,
data: {
type: "addComment",
id: e.target.text.id,
comment: {
author: e.target.author.value,
text: e.target.text.value,
},
},
});
state.data.discussions = res.data.data.execution.state.discussions;
e.target.reset();
}
</script>
<header>
<h1>Discussion Forum</h1>
</header>
<main>
<form class="discussionForm" on:submit|preventDefault={handleSubmit}>
<input type="text" name="author" placeholder="Your Alias" />
<input
type="text"
name="topic"
class="discussionInput"
placeholder="Topic of Discussion"
/>
<button type="submit">Create Discussion</button>
</form>
{#each Object.values(state.data.discussions) as discussion}
<div class="discussion">
<h4>{discussion.topic}</h4>
<p>Submitted by <strong>{discussion.author}</strong></p>
{#each Object.values(discussion.comments) as comment}
<div class="comment">
<strong>{comment.author}</strong>: {comment.text}
</div>
{/each}
<form class="commentForm" on:submit|preventDefault={handleComment}>
<input
type="text"
name="author"
class="commentName"
placeholder="Your Alias"
/>
<input
type="text"
name="text"
class="commentInput"
id={discussion.id}
placeholder="Add Comment"
/>
<button type="submit" class="commentButton">Add Comment</button>
</form>
</div>
{/each}
</main>
Whew! That is a lot of code. Let us break it into parts and see what it means.
The <script>
tag holds all the logic for the frontend. The code outside this tag deals with rendering and displaying data.
$: state = {
data: {
discussions: {},
},
};
We declare the state
as a reactive variable (with $:
) with an initial data structure as we expect to receive from the function.
onMount(async () => {
state = await axios({
method: "get",
url: `https://${functionId}.exm.run`,
});
});
Every EXM function has a standard URL format that can be interacted with using HTTP methods like GET
and POST
. The general syntax is https://${functionId}.exm.run
. Axios is a promise-based HTTP client that helps us use these methods.
In this case, our application reads the function for any existing data upon reloading the web page. The functionId
is read from the file we saved earlier upon deployment.
async function handleSubmit(e) {
const res = await axios({
method: "post",
url: `https://${functionId}.exm.run`,
data: {
type: "createDiscussion",
discussion: {
id: uuid(),
author: e.target.author.value,
topic: e.target.topic.value,
comments: [],
},
},
});
state.data.discussions = res.data.data.execution.state.discussions;
e.target.reset();
}
The handleSubmit
function makes a POST
request to the serverless function to create a new discussion thread. The author
and topic
are received as input fields from the users which we will look at slightly later. uuid
is a library that generates a unique id
for every discussion.
The result of this request also returns the updated state of functions which we store in our state
variable for displaying the latest update. The update state is received almost instantaneously from EXM’s cached layer while the actual request is processed on Arweave. Information received from EXM’s cached layer can be verified with the information stored on Arweave with the help of the evaluate method that EXM provides. Read more about this here.
Finally, the input fields are reset so that users can create new discussions.
async function handleComment(e) {
const res = await axios({
method: "post",
url: `https://${functionId}.exm.run`,
data: {
type: "addComment",
id: e.target.text.id,
comment: {
author: e.target.author.value,
text: e.target.text.value,
},
},
});
state.data.discussions = res.data.data.execution.state.discussions;
e.target.reset();
}
The handleComment
function makes a POST
request to the serverless function to add a comment to an existing discussion thread. Once again, the author
and text
for the comment are received as input fields from the users.
<form class="discussionForm" on:submit|preventDefault={handleSubmit}>
<input type="text" name="author" placeholder="Your Alias" />
<input
type="text"
name="topic"
class="discussionInput"
placeholder="Topic of Discussion"
/>
<button type="submit">Create Discussion</button>
</form>
This form call the handleSubmit
function when its submit button is clicked. The input fields send the author
and topic
to the function to pass on to our serverless function.
{#each Object.values(state.data.discussions) as discussion}
<div class="discussion">
<h4>{discussion.topic}</h4>
<p>Submitted by <strong>{discussion.author}</strong></p>
{#each Object.values(discussion.comments) as comment}
<div class="comment">
<strong>{comment.author}</strong>: {comment.text}
</div>
{/each}
<form class="commentForm" on:submit|preventDefault={handleComment}>
<input
type="text"
name="author"
class="commentName"
placeholder="Your Alias"
/>
<input
type="text"
name="text"
class="commentInput"
id={discussion.id}
placeholder="Add Comment"
/>
<button type="submit" class="commentButton">Add Comment</button>
</form>
</div>
{/each}
{#each}
is svelte’s way of mapping through objects and arrays. We map through the existing discussions within the state
and render the author
and topic
. Additionally, every discussion has another form linked to the discussion’s unique id
that lets users add a comment to a particular discussion. Just above this form are the comments posted by users previously for the given discussion.
Great job making it this far! One last bonus before the moment of truth.
Optional Styling
Replace the code in app.css
file located in src/
with the following for optional styling:
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
display: flex;
margin: 0;
min-width: 320px;
max-width: 100vw;
min-height: 100vh;
place-items: start;
}
main {
width: 90vmax;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
h4 {
margin: 0.5em 0em 0em 0em;
padding: 0;
}
.card {
padding: 2em;
}
.discussion {
background-color: silver;
border-radius: 0.5em;
display: flex;
flex-direction: column;
align-items: stretch;
margin: 0.5em 0em;
padding: 0 1em;
}
.discussionForm {
display: flex;
flex-direction: row;
}
.discussionInput {
flex-grow: 1;
margin: 0 0.5em;
}
.comment {
background-color: rgb(214 211 209);
border-radius: 0.2em;
text-align: start;
padding: 0.5em;
margin: 0.3em 0;
}
.commentForm {
display: flex;
flex-direction: row;
width: 100%;
}
.commentName {
margin: 0.3em 0.6em 0.6em 0;
}
.commentInput {
flex: 1;
margin: 0.3em 0.6em 0.6em 0;
}
.commentButton {
margin: 0.3em 0 0.6em 0;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
button {
border-radius: 0.5em;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 700;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
input {
font-weight: 300;
border-radius: 0.5em;
padding: 0.5rem;
}
p {
font-size: small;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}
Voila! We have our app.
Now for the momenth of truth we can view and interact with the application locally by running yarn dev
in the terminal and following the link provided. It should look something like this:
We can see the app has no discussions yet. Feel free to play around with it and add some to make sure the app works as expected. Once populated it should something like this withe optional styling provided:
Great job! The app works as expected, but other users still can’t interact with it.
So we upload it to the permaweb so it is accessible to other users. The permaweb is a layer on top of Arweave that enables storage of any data and hosting web apps in a permanent and decentralised manner while requiring low-cost and zero maintenance. Read more about it here.
Uploading Application on Arweave
Let’s make our application fully decentralised by uploading its frontend on Arweave as well.
We must first install the following dependencies:
yarn add -D arweave @bundlr-network/client
Then we create a wallet to interact with the network by create a request to upload our application. Every interaction (transaction) with the network requires approval from a wallet. The following command in the terminal generates a new wallet:
node -e "require('arweave').init({}).wallets.generate().then(JSON.stringify).then(console.log.bind(console))" > wallet.json
⚠️ This command above generates a wallet key file within the project. The wallet private key must be kept secret at all times and must never be published. Make sure to add
wallet.json
in the.gitignore
file.
Now we add a “script” to the package.json
file:
{
...
"scripts": {
...
"upload": "yarn build && bundlr upload-dir dist -h https://node2.bundlr.network --wallet ./wallet.json -c arweave --index-file index.html --no-confirmation"
}
}
This script makes sure the application builds as expected and exports it in a format that is required for uploading on Arweave. The second part of the script creates an upload request with the help of Bundlr. As our applications exported file size is under 120kB, we do not require any funds for the same.
Run the following command in the terminal to upload:
yarn upload
On successfully uploading, the following message will be displayed in the terminal:
Uploaded to https://arweave.net/PrOyzc-tfBvQ5FQwnoZnPPZ39OQQtuKlFPxovQI1YrM
Kudos! We have just created a fully decentralised discussion forum on Arweave. Share the link received with other users to let them interact with the application.
Interact with my forum here. Use this repo as reference if needed.
Thanks for reading! 🐘
Share your builds or reach out to us on Twitter. Happy building!