August 17, 2021
The source code for this tutorial can be found here.
Here’s a quick video demonstrating the live chat in action.
@StaticBackend in all its glory.
— Dominic St-Pierre (@dominicstpierre) August 17, 2021
216 lines of code for a ~dirty live chat page #jamstack:
✅ Login / Register
🗨️ Websockets / channel based
🔒 Data persistence
Also note that the 202 LoC is mostly React ceremony here... I think I'm building something of high value and no fluff pic.twitter.com/3fX4nuYkBE
Today in this tutorial, we’ll build a simple live chat page where users join a channel and send and receive messages.
It will be similar to our real-time collaboration tutorial. But we’re going to see a new component of SataticBackend, the server-side function.
All messages persist to the database via a server-side function called each time a message is received.
To follow along for this example you need the following:
Let’s first create a directory named server-side-sample
and initialize our
frontend application by running npm init -y
.
$> mkdir server-side-sample && cs server-side-sample
$> npm init -y
We will need the following JavaScript dependencies:
@staticbackend/js
our main JavaScript client library.react
and react-dom
we’re going to use React for this example.TypeScript
will be our languageesbuild
will be our build tool to create our application bundlehttp-server
to test our application.$> npm install @staticbackend/js typescript
$> npm install --save-dev esbuild http-server
$> npm install react react-dom
$> npm install --save-dev @types/react @types/react-dom
We create the src
directory where our client-side code will be saved.
$> mkdir src
Let’s create the scripts we’ll need to run and build our React application.
Inside the package.json
file:
"scripts": {
"start": "http-server",
"build": "esbuild --bundle --outfile=dist/chat.js src/bootstrap.tsx"
}
We’re now ready to start writing our code.
Like all our tutorials, it seems we always start with user authentication. A web application cannot go very far without authentication these days.
Let’s use our traditional form layout with two fields; email and password. We have two buttons to distinguish from the login or register action.
File: src/auth.tsx
import React from "react";
import { Backend } from "@staticbackend/js";
interface IState {
email: string;
password: string;
}
interface IProps {
onToken: (token: string, email: string) => void;
}
export class Auth extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
email: "",
password: ""
}
}
handleChanges = (field: string, e: TextEvent) => {
let s = this.state;
s[field] = e.target?.value;
this.setState(s);
}
handleLogin = async () => {
const { email, password } = this.state;
const res = await bkn.login(email, password);
if (!res.ok) {
alert(res.content);
return;
}
this.props.onToken(res.content, email);
}
handleRegister = async () => {
const { email, password } = this.state;
const res = await bkn.register(email, password);
if (!res.ok) {
alert(res.content);
return;
}
this.props.onToken(res.content, email);
}
render() {
return (
<div>
<h1>Login or register</h1>
<p>
<label>Your email</label>
<br />
<input
type="email"
onChange={this.handleChanges.bind(this, "email")}
value={this.state.email}
/>
</p>
<p>
<label>Your password</label>
<br />
<input
type="password"
onChange={this.handleChanges.bind(this, "password")}
value={this.state.password}
/>
</p>
<p>
<button onClick={this.handleLogin}>Login</button>
<button onClick={this.handleRegister}>Register</button>
</p>
</div>
)
}
}
We’re using a callback function onToken
in our pros
to send our
authentication token back to the parent once we’ve login or register successfully.
interface IProps {
onToken: (token: string, email: string) => void;
}
...
this.props.onToken(res.content, email);
If we compare the handleLogin
and handleRegister
functions, we’ll notice
that they are almost identical except for the backend function name.
const res = await bkn.login(email, password);
vs
const res = await bkn.register(email, password);
You might wonder where the bkn
global variable is coming from. It is declared
in our bootstrap.tsx
file, the application’s entrypoint. Initiating the
StaticBackend client there ensures it’s available everywhere in our client-side
application.
File: bootstrap.tsx
import React from "react";
import ReactDOM from "react-dom";
import { App } from "./app";
import { Backend } from "@staticbackend/js";
bkn = new Backend("6113e61abe103dba0d35b754", "dev");
ReactDOM.render(
<App />,
document.getElementById("app")
)
You’ll need your SB-PUBLIC-KEY
when creating a new instance of the Backend
class. Once you have your self-hosted backend running, you can create your
app by visiting http://localhost:8099 and creating an
account.
You’ll see your credentials information in the terminal running the self-hosted backend server.
We’ll go step-by-step for this one. Let’s start by looking at the state of the component.
File: src/app.tsx
import React, { ChangeEventHandler } from "react";
import { Auth } from "./auth";
import { Chat } from "./chat";
import { Backend, Payload } from "@staticbackend/js";
interface IState {
token: string | null;
username: string;
messages: Array<IMessage>;
msg: string;
}
interface IMessage {
from: string;
body: string;
}
We’re importing the Auth
component we just covered. This component will be
displayed if we don’t have a valid authentication token.
Our state has a token
field that we will be using once the Auth
component
calls our onToken
callback.
The array of IMessage
will be used to display the chat messages. The msg
field will be used to type a new message.
Let’s have a look at our component declaration and constructor.
export class App extends React.Component<any, IState> {
constructor(props: any) {
super(props);
this.state = {
token: null,
username: "",
messages: [],
msg: ""
}
}
//...
}
We initialize the component’s state with empty value for all fields.
Let’s jump straight to the rendering part of the component to focus last on the main part involving the WebSocket processes.
renderMsg = (msg: IMessage, index: number) => {
if (!msg) {
return null;
}
return (
<p key={index}>
<strong>{msg.from}</strong>:
{msg.body}
</p>
)
}
render() {
if (!this.state.token) {
return (
<Auth onToken={this.onToken} />
);
}
const msgs = this.state.messages?.map(this.renderMsg);
return (
<div>
<h1>Sample chat: {this.state.username}</h1>
<div id="messages">{msgs}</div>
<p>
<input
type="text"
placeholder="enter a new message"
onChange={this.handleMsg}
onKeyPress={this.handleSubmit}
value={this.state.msg}
/>
</p>
</div>
)
}
If we do not have a valid authentication token, we display our Auth component. Otherwise, we’ll show the chat messages and an input field to write a new message.
The renderMsg
is a small helper to format a IMessage
in HTML.
We’re finally at the main step. What happened when our Auth
component calls
the onToken
callback.
These are the events that are handle in the onToken
callback:
Here’s the onToken
callback:
onToken = (token: string, email: string) => {
this.setState({ token: token, username: email });
bkn.connect(token,
(tok: string) => {
console.log("socket connected");
bkn.send(bkn.types.join, "lobby");
},
(pl: Payload) => {
switch (pl.type) {
case bkn.types.ok:
if (pl.data == "lobby") {
(async () => {
// get all messages from the channel
console.log("loading previous message");
const res = await bkn.list(token, "msgs_774_");
if (!res.ok) {
alert("error listing messages from db: \n" + res.content);
return;
}
let { messages } = this.state;;
res.content.results?.forEach((m) => messages.push(m));
this.setState({ messages: messages });
})();
}
break;
case bkn.types.joined:
let { messages } = this.state;;
messages.push({
from: "system",
body: `${pl.data} joins the channel`
});
this.setState({ messages: messages });
break;
case bkn.types.chanOut:
try {
const msg = JSON.parse(pl.data);
let { messages } = this.state;
messages.push(msg);
this.setState({ messages: messages });
} catch (ex) {
console.error(pl);
alert(ex);
return;
}
};
}
);
}
The first step in our onToken
callback is to set the component state
indicating we have an authentication token.
this.setState({ token: token, username: email });
Next we want to connect to establish the WebSocket communication:
To do that we use the connect
function of our client library.
connect(token: string, (tok: string) => void, (pl: Payload) => void)
The connect
function wants three arguments:
We can then call the connect
function like this:
bkn.connect(token,
(tok: string) => {
console.log("socket connected");
bkn.send(bkn.types.join, "lobby");
},
(pl: Payload) => {}
);
The first argument is the token we got from our Auth
component.
The second argument is a callback function we use to join a channel named lobby
.
This function gets called if our WebSocket connection is successful.
The last argument is a callback function that gets called everytime a new message is received.
It’s usually standard to perform a switch
operation on the type
field when
receiving messages. This is the data model representing messages:
"sid": "unique socket id",
"type": "one of supported types",
"data": "a string representing the payload",
"channel": "if the message target a channel",
"token": "a special token obtained via auth"
}
The data
field is a string
but usually contains JSON parsable format.
Let’s inspect our switch
condition more closely:
switch (pl.type) {
case bkn.types.ok:
if (pl.data == "lobby") {
(async () => {
// get all messages from the channel
console.log("loading previous message");
const res = await bkn.list(token, "msgs_774_");
if (!res.ok) {
alert("error listing messages from db: \n" + res.content);
return;
}
let { messages } = this.state;;
res.content.results?.forEach((m) => messages.push(m));
this.setState({ messages: messages });
})();
}
break;
// other cases
}
This first case handles a generic type: bkn.types.ok
. Whenever we send
messages, we’re getting a reply in the OK
or ERR
state. We’re using the
OK
state of our join
command to fetch the previous message in our database.
Since all command we use will have an OK
returned, we’re using the data
field to filter for the join
resulting command.
Let’s see how we’re notifying when someone joins the channel:
case bkn.types.joined:
let { messages } = this.state;;
messages.push({
from: "system",
body: `${pl.data} joins the channel`
});
this.setState({ messages: messages });
break;
We’re manually adding a message to the messages
array each time a new person
join.
Lastly let’s have a look at out chan_out
case, when a new message is
posted to the channel:
case bkn.types.chanOut:
try {
const msg = JSON.parse(pl.data);
let { messages } = this.state;
messages.push(msg);
this.setState({ messages: messages });
} catch (ex) {
console.error(pl);
alert(ex);
return;
}
We JSON.parse
the data
field of our Payload
data model. Since it’s a
string
we need to parse it to get a JavaScript object.
From there we can add it to our array of messages and update the component’s state.
The last piece remaining to see for the client-side application is the
submission of the new message. We’re using an onKeyPress
handler that posts
to the lobby
channel when we hit the Enter
key:
handleSubmit = (e: any) => {
if (e.charCode == 13) {
e.preventDefault();
const msg: IMessage = {
from: this.state.username,
body: this.state.msg
}
bkn.send(bkn.types.chanIn, JSON.stringify(msg), "lobby");
this.setState({ msg: "" });
}
}
To send a message to a channel, we use the chan_in
type command with the send
function.
The last part of this tutorial will focus on creating a server-side function that executes each time there’s a new message posted to a channel.
StaticBackend’s server-side functions are sandboxed JavaScript functions that run on the server with a custom runtime. You have access to most StaticBackend resources like the database, posting messages, sending emails, etc.
The functions trigger based on messages or topics. Remember we had a case
where we were handling the bkn.types.chanOut
message type earlier. So we can
have a server-side function trigger based on that same event type.
To create the function, we’ll use our CLI. Note that you can get started with the CLI here. You may also use the web UI of StaticBackend to create the function.
$> backend function add --name new_msg --trigger chan_out --source ./fn.js
We specify the name of the function, its trigger, and the JavaScript source file executed.
This is the function source:
file: fn.js
function handle(body) {
log("DEBUG: inside ssf", body);
try {
var msg = JSON.parse(body.data);
msg.sentOn = new Date();
var res = create("msgs_774_", msg);
if (!res.ok) {
log("unable to create msg", res.content);
return;
}
log("success");
} catch(ex) {
log("error parsing data", ex);
}
}
All functions must have a handle
function name. This handler will receive important
arguments by the runtime, like body. In our case, we’re receiving the Payload
of a new message being posted on a channel.
We parse the data
field and add a sentOn
field just for demonstration
purposes.
We then create the message entry to a msgs
collection. Please note that our
collection “msgs_774
” has specifics permissions. We’re enabling everyone
(authenticated) to read this collection. Please refer to our
documentation to understand how you
can opt-in to specifics permission for your collection.
This tutorial demonstrates some building blocks of StaticBackend that help you launch your application faster. We have got the following functionalities for free in a small codebase:
If you haven’t tested StaticBackend yet, we will encourage you to do so and see for yourself how it can help you. We’re happy to hear your feedback and any questions you have. We’re here to help.
© 2023 Focus Centric Inc. All rights reserved.