Build a Chat App with Stream & Kong

Uk C.
Uk C.
Published January 22, 2020 Updated August 28, 2020

Managing microservices manually can get very complicated and takes the focus away from business logic. API gateways help take care of the various collective management and housekeeping tasks necessary for running microservices.

One of the most popular API gateways is Kong. As the illustration below shows, Kong takes care of the tedious tasks involved in managing and running microservices while leaving you to focus on building exciting features by leveraging on products like Stream Chat for live chats or Stream Activity Feeds for creating engaging activity feeds.

In this tutorial, we will be creating a live chat app using Stream Chat and a backend microservice exposed through Kong's API gateway.

Prerequisites

Before you proceed with this tutorial, make sure you have Docker, Node.js, and npm or yarn installed on your machine. We'll be building the frontend with Svelte and the backend with Express. Both are chosen because of their simplicity and so that you can understand and follow along even if this is your first encounter with them. Being minimalist tools, they don't distract from what's going on.

Sign up for Stream Chat

Visit this link to create a free Stream account or login to your existing account.

Once you’re logged in, you can use the app that's automatically created for you. Or, if you prefer, go to the dashboard, hit the blue “Create App” button at the top right of the screen and give your app a name as shown below:

Image shows an app being created in the stream dashboard

In the App Access Keys section at the bottom, copy your Key and Secret for use later.

Kong Setup

We'll be setting up Kong using Docker to keep our local machine clean and free of dependencies, and using Docker ensures that the steps are the same for all platforms.

We need to create a Docker network for Kong and our microservices, then setup Kong's database where its configurations will be stored, and start-up Kong after that.

$ docker network create kong-net

$ docker run -d --name kong-database --network=kong-net \
  -p 5432:5432 -e "POSTGRES_USER=kong" \
  -e "POSTGRES_DB=kong" postgres:9.6

$ docker run --rm --network=kong-net -e "KONG_DATABASE=postgres" \
  -e "KONG_PG_HOST=kong-database" -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \
  kong:latest kong migrations bootstrap

$ docker run -d --name kong --network=kong-net -e "KONG_DATABASE=postgres" \
  -e "KONG_PG_HOST=kong-database" -e "KONG_CASSANDRA_CONTACT_POINTS=kong-database" \
  -e "KONG_PROXY_ACCESS_LOG=/dev/stdout" -e "KONG_ADMIN_ACCESS_LOG=/dev/stdout" \
  -e "KONG_PROXY_ERROR_LOG=/dev/stderr" -e "KONG_ADMIN_ERROR_LOG=/dev/stderr" \
  -e "KONG_ADMIN_LISTEN=0.0.0.0:8001, 0.0.0.0:8444 ssl" -p 8000:8000 -p 8443:8443 \
  -p 8001:8001 -p 8444:8444 kong:latest

You can verify that Kong is running with:

bash
1
$ curl -i http://localhost:8001/

For Kong to know about our microservice, we need to configure the microservice and tell Kong how to route requests to our app. But, we first have to create the microservice.

Setting up the Chat Microservice

Let's create our folder structure, initialize the project, and install the needed dependencies:

$ mkdir -p kongchat/{frontend,backend}
$ cd kongchat/backend
$ touch index.js
$ npm init -y
$ npm i express body-parser dotenv stream-chat ip

After that, create a file called index.js in the backend directory and paste in the code below:

require('dotenv').config();

const express = require('express');
const bodyParser = require('body-parser');
const ip = require('ip');
const { StreamChat } = require('stream-chat');
const app = express();

// Add the middlewares
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));

// Initialize our Stream Chat client
const streamClient = new StreamChat(process.env.KC_STREAM_CHAT_KEY, process.env.KC_STREAM_CHAT_SECRET);

app.get('/api/kongchat/ping', (req, res) => {
  return res.json({ message: 'pong' });
});

app.post('/api/kongchat/token', (req, res) => {
  const { body: { username } } = req;

  // generate a Stream Chat token for use by the user making the request
  return res.json({ user: { username }, token: streamClient.createToken(username) });
});

app.listen(10000, () => {
  console.log(`API running on http://${ip.address()}:10000`);
});

We have two routes. The /token endpoint accepts a username and generates a new token using the Stream Chat Node.js SDK, as we need to authenticate any new users before they can gain access. The /ping endpoint is to check if the server is running.

Create a .env file in the current directory (backend) and add your key and secret you copied from your Stream dashboard:

KC_STREAM_CHAT_KEY=xxxx
KC_STREAM_CHAT_SECRET=xxxx

In the scripts section of package.json in the backend directory, add a start command to run the server as shown below:

{
  "scripts": {
    "start": "node index.js"
  },
}

Start the server with:

bash
1
$ npm start

Now that we've successfully written our backend microservice let's Dockerize it so Kong can manage it.

Add a Dockerfile in the backend directory and paste in:

FROM node:carbon-alpine

WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 10000

CMD ["npm", "start"]

Then, in the root directory (kongchat) add a docker-compose.yml file and paste in:

version: '3'
services:
   kc_backend:
    build: ./backend
    image: kc_backend
    container_name: kc_backend
    network_mode: kong-net

With this, we can run our backend service using:

bash
1
$ docker-compose up -d

Or, if you prefer it to run in the foreground:

bash
1
$ docker-compose up

Registering Our Microservice With Kong

Now our back-end microservice is up and running; we need to tell Kong about it. To do so, we need to know its IP address on the kong-net network. We can find that out by running:

$ docker network inspect kong-net
# Sample output
[
    {
        "Name": "kong-net",
        "Id": "1176776a97a0d22a44789cdd8fb4408cb80e4af086ac6474ab503704fda06c40",
        # ...
        "Containers": {
            # ...            "3a21018c6c9a6c0877ac182e7cd0e3e3da0af87b36d1f2a4882f76fe43a03a27": {
                "Name": "kc_backend",
                "EndpointID": "8e7aec61796e745cff0e0e79d3e723b76dba52305c91d19aade6825a3e43d6e9",
                "MacAddress": "02:42:ac:13:00:04",
                "IPv4Address": "172.19.0.4/16",
                "IPv6Address": ""
            },
            # ...
        }
    }
]
Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

My microservice IP address, for example, is 172.19.0.4, as seen from the IPv4Address key of the Containers field. I omitted the irrelevant parts of the output.

Now we have our IP address in hand; we register it with Kong by making a POST request to Kong's services endpoint:

$ curl -i -X POST --url http://localhost:8001/services/ --data 'name=kc-chat-backend' \
  --data 'url=http://172.19.0.4' --data 'port=10000'
# Sample output

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/1.4.3
Content-Length: 295
X-Kong-Admin-Latency: 48

{
  "host": "172.19.0.4",
  "created_at": 1579439597,
  "connect_timeout": 60000,
  "id": "91e5cec5-abee-4b6c-b07c-11643a1678af",
  "protocol": "http",
  "name": "kc-chat-backend",
  "read_timeout": 60000,
  "port": 10000,
  "path": null,
  "updated_at": 1579439597,
  "retries": 5,
  "write_timeout": 60000,
  "tags": null,
  "client_certificate": null
}

Now Kong knows about our microservice. We then need to tell Kong what requests to route to our microservice:

$ curl -i -X POST --url http://localhost:8001/services/kc-chat-backend/routes \
  --data 'paths[]=/api/kongchat' --data 'strip_path=false' --data 'methods[]=GET' \
  --data 'methods[]=POST'

# Sample output

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/1.4.3
Content-Length: 407
X-Kong-Admin-Latency: 30

{
  "id": "4fd4c22f-2760-49e2-9dda-64435ef4553f",
  "tags": null,
  "destinations": null,
  "headers": null,
  "protocols": [
    "http",
    "https"
  ],
  "snis": null,
  "service": {
    "id": "91e5cec5-abee-4b6c-b07c-11643a1678af"
  },
  "name": null,
  "preserve_host": false,
  "regex_priority": 0,
  "strip_path": false,
  "sources": null,
  "paths": [
    "/api/kongchat"
  ],
  "https_redirect_status_code": 426,
  "hosts": null,
  "methods": [
    "GET",
    "POST"
  ]
}

With that, we just told Kong to route GET and POST requests made to /api/kongchat to our microservice.

We can check that Kong is correctly routing our API requests to our microservice by sending a request to /ping:

$ curl -i -X GET --url http://localhost:8000/api/kongchat/ping

# Sample output

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 18
Connection: keep-alive
X-Powered-By: Express
ETag: W/"12-6FyCUNJCdUkgXM8yXmM99u6fQw0"
X-Kong-Upstream-Latency: 22
X-Kong-Proxy-Latency: 1
Via: kong/1.4.3

{
  "message":"pong"
}

Since we'll be communicating with Kong from the browser, we need to turn on the CORS plugin for our service:

$ curl -X POST http://kong:8001/services/kc-chat-backend/plugins \
    --data "name=cors" \
    --data "config.origins=*" \
    --data "config.methods=GET" \
    --data "config.methods=POST" \
    --data "config.methods=OPTIONS" \
    --data "config.headers=Accept" \
    --data "config.headers=Accept-Version" \
    --data "config.headers=Content-Length" \
    --data "config.headers=Content-MD5" \
    --data "config.headers=Content-Type" \
    --data "config.headers=Host" \
    --data "config.headers=Date" \
    --data "config.headers=X-Auth-Token" \
    --data "config.exposed_headers=X-Auth-Token" \
    --data "config.credentials=true" \
    --data "config.max_age=3600"

Creating Our Frontend

In the frontend directory, we created earlier, run:

npx degit sveltejs/template .
npm i  # install
npm i stream-chat dotenv
npm i -D postcss postcss-load-config svelte-preprocess tailwindcss @fullhuman/postcss-purgecss @rollup/plugin-replace # development dependencies
npx tailwind init
touch postcss.config.js
cp ../backend/.env . # Make a copy of backend's .env in frontend
npm run dev # and visit http://localhost:5000 in the browser

We're using Tailwind CSS for our styling. So let's integrate it into our development pipeline.
In postcss.config.js, paste in:

const purgecss = require('@fullhuman/postcss-purgecss')({
  content: ['./src/**/*.svelte', './src/**/*.html'],
  whitelistPatterns: [/svelte-/],
  defaultExtractor: content => content.match(/[A-Za-z0-9-_:/]+/g) || []
})

module.exports = {
  plugins: [
    require('tailwindcss'),
    ...(!process.env.ROLLUP_WATCH ? [purgecss] : [])
  ]
};

Replace the contents of rollup.config.js with:

import svelte from 'rollup-plugin-svelte';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import livereload from 'rollup-plugin-livereload';
import { terser } from 'rollup-plugin-terser';
import sveltePreprocess from 'svelte-preprocess';
import replace from '@rollup/plugin-replace';
import { config } from 'dotenv';

config();
const production = !process.env.ROLLUP_WATCH;

export default {
	input: 'src/main.js',
	output: {
		sourcemap: true,
		format: 'iife',
		name: 'app',
		file: 'public/build/bundle.js'
	},
	plugins: [
		svelte({
			// enable run-time checks when not in production
			dev: !production,
			preprocess: sveltePreprocess({ postcss: true }),
			// we'll extract any component CSS out into
			// a separate file — better for performance
			css: css => {
				css.write('public/build/bundle.css');
			}
		}),
		replace({
			process: JSON.stringify({
				env: {
					streamChatKey: process.env.KC_STREAM_CHAT_KEY,
					streamChatSecret: process.env.KC_STREAM_CHAT_SECRET,
					kongHost: process.env.KC_KONG_URL
				}
			})
		}),

		// If you have external dependencies installed from
		// npm, you'll most likely need these plugins. In
		// some cases you'll need additional configuration —
		// consult the documentation for details:
		// https://github.com/rollup/plugins/tree/master/packages/commonjs
		resolve({
			browser: true,
			dedupe: importee => importee === 'svelte' || importee.startsWith('svelte/')
		}),
		commonjs(),

		// In dev mode, call `npm run start` once
		// the bundle has been generated
		!production && serve(),

		// Watch the `public` directory and refresh the
		// browser on changes when not in production
		!production && livereload('public'),

		// If we're building for production (npm run build
		// instead of npm run dev), minify
		production && terser()
	],
	watch: {
		clearScreen: false
	}
};

function serve() {
	let started = false;

	return {
		writeBundle() {
			if (!started) {
				started = true;

				require('child_process').spawn('npm', ['run', 'start', '--', '--dev'], {
					stdio: ['ignore', 'inherit', 'inherit'],
					shell: true
				});
			}
		}
	};
}

Our changes complete the setup of our CSS pre-processing and inject our environment variables for use in the app.

Modify the style tag of App.svelte as below:

<style global>
  @tailwind base;
  @tailwind components;
  @tailwind utilities;
</style>

That's all for our CSS pre-processing. Let's dive into the mechanism of connecting to our proxied microservice.

When a user first loads the app, we want to show the login page below:

When the user types a username and clicks on Sign in, we want to show the chat window like the example below:

The contents of our App.svelte is shown below:

<script>
import { onMount } from 'svelte';
import { StreamChat } from 'stream-chat/dist/index.js';

export let appName;
let loggedIn = false;
let online = false;
let token = '';
let username = '';
let message = '';
let messages = [];
let channel = null;
let user = null;
let streamClient = null;
let pingInterval = 30000; // 30 seconds

	
const initializeStreamChat = async () => {
    if (!user) return;

    const { username } = user;
    streamClient = new StreamChat(process.env.streamChatKey);
    await streamClient.setUser({ id: username, name: username, role: 'admin' }, token);
};

const initializeChannel = () => {
    channel = streamClient.channel('livestream', 'kongchat', { name: appName });

    return channel.watch();
};

const joinChat = (_) => {
    if (!username) {
        alert('Username is empty');
        return;
    }

    fetch(`${process.env.kongHost}/api/kongchat/token`, { 
        method: 'POST',
        body: JSON.stringify({ username }),
        headers: {
            'Content-Type': 'application/json'
        }
    })
        .then(response => response.ok ? response.json() : ({}))
        .then((response) => {
            user = response.user;
            token = response.token;
        })
        .then(initializeStreamChat)
        .then(initializeChannel)
        .then((room) => {
            loggedIn = true;
            messages = room.messages;

            channel.on('message.new', ({ message: { text, user } }) => {
                messages = [...messages, { text, user }];
            });
        })
        .catch(console.error);
};

const send = (_) => {
    channel.send({ text: message });
    message = '';
};

const pingService = () => {
    fetch(`${process.env.kongHost}/api/kongchat/ping`, { method: 'GET' })
        .then(response => response.ok ? response.json() : ({ message: 'not pong' }))
        .then(({ message }) => {
            online = message === 'pong';
        })
        .finally(() => {
            setTimeout(pingService, pingInterval);
        });
};

onMount(() => {
    pingService();
});
</script>

<main>
	<h1 class="text-4xl text-center">
		{appName}
	</h1>

	{#if online}
		<span class="bg-green-500 rounded-full h-5 w-5 flex mx-auto"></span>
	{:else}
		<span class="bg-red-500 rounded-full h-5 w-5 flex mx-auto"></span>
	{/if}

	{#if loggedIn}
		<div class="container mx-auto">
			<h5>You are <strong>{user.username}</strong></h5>
			<br>

			{#each messages as message}
				<div class="w-full max-w my-1">
					<div class="border border-gray-400 bg-white rounded p-4 flex flex-col justify-between leading-normal">
						<div class="mb-8">
							<p class="text-gray-700 text-base">{message.text}</p>
						</div>
						<div class="flex items-center">
							<div class="text-sm">
								<p class="text-gray-900 leading-none">By {message.user.id === user.username ? 'You' : message.user.name}</p>
							</div>
						</div>
					</div>
				</div>
			{/each}

      <form>
				<div class="flex items-center border-b border-b-2 border-teal-500 py-2 mb-10">
					<input bind:value={message} class="appearance-none bg-transparent border-none w-full text-gray-700 mr-3 py-1 px-2 leading-tight focus:outline-none" type="text" placeholder="Message">
					<button on:click|preventDefault={send} class="flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded" type="button">
						Send
					</button>
				</div>
      </form>
		</div>
  {:else}
		<div class="w-full mx-auto max-w-xs">
			<form on:submit|preventDefault={joinChat} class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
				<div class="mb-4">
					<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
						Username
					</label>
					<input bind:value={username} class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="text" placeholder="Username">
				</div>
				<div class="flex items-center justify-between">
					<button class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
						Sign In
					</button>
				</div>
			</form>
		</div>
  {/if}
	<p class="text-center text-gray-500 text-xs inset-x-0 bottom-0 absolute">
		&copy;{new Date().getFullYear()} {appName}
	</p>
</main>

<style global>
	@tailwind base;
  @tailwind components;
  @tailwind utilities;
</style>

Let's go through the code.

At the beginning of our component's template, we have the app name and an indicator that shows if our microservice is up and running:

<h1 class="text-4xl text-center">
    {appName}
</h1>

{#if online}
	<span class="bg-green-500 rounded-full h-5 w-5 flex mx-auto"></span>
{:else}
	<span class="bg-red-500 rounded-full h-5 w-5 flex mx-auto"></span>
{/if}

We call the /ping endpoint we created on the microservice using pingService() at intervals determined by pingInterval.

When the user first opens the app (s)he is not logged in (loggedIn is false) so we show the login form:

{#if loggedIn}
	<!-- -->
{:else}
  <div class="w-full mx-auto max-w-xs">
    <form on:submit|preventDefault={joinChat} class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
        <div class="mb-4">
            <label class="block text-gray-700 text-sm font-bold mb-2" for="username">
                Username
            </label>
            <input bind:value={username} class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="text" placeholder="Username">
        </div>
        <div class="flex items-center justify-between">
            <button class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
                Sign In
            </button>
        </div>
    </form>
  </div>
{/if}

Submitting the login form will call joinChat() which sends a request to the Kong gateway to get a token. When the token is returned, we initialize Stream Chat (initializeStreamChat()) and our messaging channel (initializeChannel()), then we set loggedIn to true, retrieve previous messages (if any) and register a listener for new messages to the channel.
We then show the chat window for logged in users:

{#if loggedIn}
  <div class="container mx-auto">
    <h5>You are <strong>{user.username}</strong></h5>
    <br>

    {#each messages as message}
    <div class="w-full max-w my-1">
        <div class="border border-gray-400 bg-white rounded p-4 flex flex-col justify-between leading-normal">
            <div class="mb-8">
                <p class="text-gray-700 text-base">{message.text}</p>
            </div>
            <div class="flex items-center">
                <div class="text-sm">
                    <p class="text-gray-900 leading-none">By {message.user.id === user.username ? 'You' : message.user.name}</p>
                </div>
            </div>
        </div>
    </div>
    {/each}

    <form>
        <div class="flex items-center border-b border-b-2 border-teal-500 py-2 mb-10">
            <input bind:value={message} class="appearance-none bg-transparent border-none w-full text-gray-700 mr-3 py-1 px-2 leading-tight focus:outline-none" type="text" placeholder="Message">
            <button on:click|preventDefault={send} class="flex-shrink-0 bg-teal-500 hover:bg-teal-700 border-teal-500 hover:border-teal-700 text-sm border-4 text-white py-1 px-2 rounded" type="button">
                Send
            </button>
        </div>
    </form>
  </div>
{:else}
<!-- -->
{/if}

When the Send button is clicked, we publish the contents of message to the channel and empty it after that, and Stream Chat ensures that all registered clients get the newly published message.

Demo

Open the app in different browser windows, log in with different usernames, and you can send messages between the logged-in users as shown in the images below:

Conclusion

You can find out more about Stream Chat and Kong by going through their documentation. The source code for this tutorial is hosted on GitHub.

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->