Demo - Boomerang Chat
The Boomerang Chat is a chat that sends a message to the HOPR Network which comes back to its original recipient. Because nodes in the network have no idea who sent the message and who’s the final recipient, a node can always send a message to itself and no node would be the wiser. At the end, a HOPR node only knows who to forward the message next.
This demo showcases the minimal requirements needed to build a HOPR app. The application is React
-based web application that
uses native WebSockets to connect to your HOPR node, and its REST API to send messages to itself.
Requirements
- a HOPR cluster, and at least a single HOPR node with their respective
HTTP_ENDPOINT
,WS_ENDPOINT
andAPI_TOKEN
- a React-ready web framework used to run the application. We recommend
next.js
.
For simplicity, you can also see the code for this demo in Codesandbox. To see an example of how to run this as an isolated application, you can also check our demo chat application, MyneChat.
Building the Boomerang Chat
1. Connecting to our HOPR nodes
Our React application starts as with any, just loading the application within an entry point.
import { render } from 'react-dom'
import BoomerangChat from './BoomerangChat'
const rootElement = document.getElementById('root')
render(<BoomerangChat />, rootElement)
We can see how we are loading our app in line 3
and rendering it on line 6
.
BoomerangChat.jsx
renders the actual component we are looking to build as a HOPR App, so let's take a look at BoomerangChat.jsx
:
import React, { useEffect, useState } from "react";
import WebSocketHandler from "./WebSocketHandler";
export default function BoomerangChat() {
const [message, setMessage] = useState("Hello world");
const [securityToken, setSecurityToken] = useState("");
const [wsEndpoint, setWsEndpoint] = useState("ws://localhost:3000");
const [httpEndpoint, setHTTPEndpoint] = useState("http://localhost:3001");
const [address, setAddress] = useState("");
useEffect(() => {
const loadAddress = async () => {
...
};
loadAddress();
}, [securityToken, httpEndpoint]);
const sendMessage = async () => {
...
};
return (
<div>
...
<WebSocketHandler wsEndpoint={wsEndpoint} securityToken={securityToken} />
</div>
);
}
We can see that from line 6
to 8
, we see a series of values called httpEndpoint
, wsEndpoint
, and securityToken
,
which are needed in most HOPR Applications. Without these values, configurable by the user, a HOPR app can
not communicate with a HOPR node, and thus, without a HOPR cluster.
We also see in line 25
a component called WebSocketHandler
. Here is its code:
import React, { useEffect, useState } from "react";
import useWebsocket from "../hooks/useWebSockets ";
export const WebSocketHandler = ({ wsEndpoint, securityToken }) => {
const [message, setMessage] = useState("");
const websocket = useWebsocket({ wsEndpoint, securityToken });
const { socketRef } = websocket;
const handleReceivedMessage = async (ev) => {
...
};
useEffect(() => {
if (!socketRef.current) return;
socketRef.current.addEventListener("message", handleReceivedMessage);
return () => {
if (!socketRef.current) return;
socketRef.current.removeEventListener("message", handleReceivedMessage);
};
}, [socketRef.current]);
...
};
Here things start getting interesting.
- We are attempting to connnect to our HOPR node WebSocket endpoint on load, and
- When we succeed, we attach an event handler to every message it receives.
Of course, you could always just try and connect on request rather than on load. For now, the easiest way is
to use the useWebSocket
helper (you can find the code at the bottom) to initialize the WebSocket
interface
and retries on changes for the wsEndpoint
value.
2. Obtaining information about your HOPR node
On top of the WebSocket endpoint, a HOPR app should also try to use a HOPR node's REST API endpoint to display useful information about it in the app, such as address and balance.
In our previous example, inside BoomerangChat.jsx
we had a useEffect
call, which has something like this:
useEffect(() => {
const loadAddress = async () => {
const headers = getHeaders()
const account = await fetch(`${httpEndpoint}/api/v2/account/address`, {
headers
})
.then((res) => res.json())
.catch((err) => console.error(err))
setAddress(account?.hoprAddress)
}
loadAddress()
}, [securityToken, httpEndpoint])
In line 4
we are talking to the node's REST API using the native fetch
call, to obtain its HOPR Address, since
we’ll be using that later when we want to send a message. It's important to include the Headers
(see next line)
which authenticate the client against the call to avoid unauthorized calls. Here's the getHeaders
method for reference:
const getHeaders = (isPost = false) => {
const headers = new Headers()
if (isPost) {
headers.set('Content-Type', 'application/json')
headers.set('Accept-Content', 'application/json')
}
headers.set('Authorization', 'Basic ' + btoa(securityToken))
return headers
}
3. Sending the message via the REST API
The final part of the demo revolves around the actual request to send the message. We can see the code from the same file here:
const sendMessage = async () => {
if (!address) return
await fetch(`${httpEndpoint}/api/v2/messages`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({
recipient: address,
body: message
})
}).catch((err) => console.error(err))
}
We can see that the message is being sent via the REST API endpoint, including also headers (which are now different)
since we are doing a POST
request. The important part is on line 7
, where we state the recipient
of the message
is address
, value which we set up in the initial useEffect
request. This is how we are signaling to the API to send
a message to that particular address, which happens to be ourselves. To the HOPR Network the destination of the message
is irrelevant.
Demo: Boomerang
You can see the application working and running within this same page. Give it a try! Don’t forget that you need at least a running HOPR cluster for it to work.
You have no messages.
Annex: Components/hooks code
Here's all the code for all components and hooks used.
useWebSocket.js
Used as a WebSocket interface to react and connect to our HOPR node accordingly.
/*
A react hook.
Keeps websocket connection alive, reconnects on disconnections or endpoint change.
*/
import { useImmer } from 'use-immer'
import { useEffect, useRef, useState } from 'react'
import debounce from 'debounce'
const useWebsocket = (settings) => {
// update timestamp when you want to reconnect to the websocket
const [reconnectTmsp, setReconnectTmsp] = useState()
const [state, setState] = useImmer({ status: 'DISCONNECTED' })
const socketRef = useRef()
const setReconnectTmspDebounced = debounce((timestamp) => {
setReconnectTmsp(timestamp)
}, 1e3)
const handleOpenEvent = () => {
console.info('WS CONNECTED')
setState((draft) => {
draft.status = 'CONNECTED'
return draft
})
}
const handleCloseEvent = () => {
console.info('WS DISCONNECTED')
setState((draft) => {
draft.status = 'DISCONNECTED'
return draft
})
setReconnectTmspDebounced(+new Date())
}
const handleErrorEvent = (e) => {
console.error('WS ERROR', e)
setState((draft) => {
draft.status = 'DISCONNECTED'
draft.error = String(e)
})
setReconnectTmspDebounced(+new Date())
}
// runs everytime "endpoint" or "reconnectTmsp" changes
useEffect(() => {
if (typeof window === 'undefined') return // We are on SSR
// disconnect from previous connection
if (socketRef.current) {
console.info('WS Disconnecting..')
socketRef.current.close(1000, 'Shutting down')
}
// need to set the token in the query parameters, to enable websocket authentication
try {
const wsUrl = new URL(settings.wsEndpoint)
if (settings.securityToken) {
wsUrl.search = `?apiToken=${settings.securityToken}`
}
console.info('WS Connecting..')
socketRef.current = new WebSocket(wsUrl)
// handle connection opening
socketRef.current.addEventListener('open', handleOpenEvent)
// handle connection closing
socketRef.current.addEventListener('close', handleCloseEvent)
// handle errors
socketRef.current.addEventListener('error', handleErrorEvent)
} catch (err) {
console.error('URL is invalid', settings.wsEndpoint)
}
// cleanup when unmounting
return () => {
if (!socketRef.current) return
socketRef.current.removeEventListener('open', handleOpenEvent)
socketRef.current.removeEventListener('close', handleCloseEvent)
socketRef.current.removeEventListener('error', handleErrorEvent)
}
}, [settings.wsEndpoint, settings.securityToken])
return {
state,
socketRef
}
}
export default useWebsocket
WebSocketHandler.jsx
Used to a React component to load our HOPR node using the useWebSocket
hook.
import React, { useEffect, useState } from 'react'
import useWebsocket from '../hooks/useWebSockets '
export const WebSocketHandler = ({ wsEndpoint, securityToken }) => {
const [message, setMessage] = useState('')
const websocket = useWebsocket({ wsEndpoint, securityToken })
const { socketRef } = websocket
const handleReceivedMessage = async (ev) => {
try {
// we are only interested in messages, not all the other events coming in on the socket
const data = JSON.parse(ev.data)
console.log('WebSocket Data', data)
if (data.type === 'message') {
setMessage(data.msg)
}
} catch (err) {
console.error(err)
}
}
useEffect(() => {
if (!socketRef.current) return
socketRef.current.addEventListener('message', handleReceivedMessage)
return () => {
if (!socketRef.current) return
socketRef.current.removeEventListener('message', handleReceivedMessage)
}
}, [socketRef.current])
return <span>{message ? message : 'You have no messages.'}</span>
}
export default WebSocketHandler
BoomerangChat.jsx
Actual HOPR application able to send and read a message to the same node it’s connected usign its HTTP/WS endpoints.
import React, { useEffect, useState } from 'react'
import WebSocketHandler from './WebSocketHandler'
export default function BoomerangChat() {
const [message, setMessage] = useState('Hello world')
const [securityToken, setSecurityToken] = useState('')
const [wsEndpoint, setWsEndpoint] = useState('ws://localhost:3000')
const [httpEndpoint, setHTTPEndpoint] = useState('http://localhost:3001')
const [address, setAddress] = useState('')
const getHeaders = (isPost = false) => {
const headers = new Headers()
if (isPost) {
headers.set('Content-Type', 'application/json')
headers.set('Accept-Content', 'application/json')
}
headers.set('Authorization', 'Basic ' + btoa(securityToken))
return headers
}
useEffect(() => {
const loadAddress = async () => {
const headers = getHeaders()
const account = await fetch(`${httpEndpoint}/api/v2/account/address`, {
headers
})
.then((res) => res.json())
.catch((err) => console.error(err))
setAddress(account?.hoprAddress)
}
loadAddress()
}, [securityToken, httpEndpoint])
const sendMessage = async () => {
if (!address) return
await fetch(`${httpEndpoint}/api/v2/messages`, {
method: 'POST',
headers: getHeaders(true),
body: JSON.stringify({
recipient: address,
body: message
})
}).catch((err) => console.error(err))
}
return (
<div>
<div>
<label>WS Endpoint</label>{' '}
<input
name="wsEndpoint"
placeholder={wsEndpoint}
value={wsEndpoint}
onChange={(e) => setWsEndpoint(e.target.value)}
/>
</div>
<div>
<label>HTTP Endpoint</label>{' '}
<input
name="httpEndpoint"
placeholder={httpEndpoint}
value={httpEndpoint}
onChange={(e) => setHTTPEndpoint(e.target.value)}
/>
</div>
<div>
<label>Security Token</label>{' '}
<input
name="securityToken"
placeholder={securityToken}
value={securityToken}
onChange={(e) => setSecurityToken(e.target.value)}
/>
</div>
<div>
<label>Send a message</label>{' '}
<input name="httpEndpoint" value={message} placeholder={message} onChange={(e) => setMessage(e.target.value)} />
</div>
<button onClick={() => sendMessage()}>Send message to node</button>
<br />
<br />
<WebSocketHandler wsEndpoint={wsEndpoint} securityToken={securityToken} />
</div>
)
}