DEV Community

Cover image for Part 2: Connecting everything together
Evertvdw
Evertvdw

Posted on • Edited on

Part 2: Connecting everything together

In this series we are going to create an embeddable chat widget that you can insert on any website. in part 1 we setup the basic repository, using yarn workspaces. However, when I got going coding stuff for this part of the series, I quickly noticed I should have added the different parts portal, widget and server as folders under /packages and not in the root folder.

If they are not under /packages adding packages to a workspace will not work as expected, creating extra yarn.lock files and node_modules folders.

Fixing workspaces setup of part 1

Anyways, this can of course be fixed, so lets do that first πŸ™‚

  1. Create a new folder packages in the root directory. Move the server, portal and widget folders in here.
  2. Update workspaces in root package.json to ["packages/*"]
  3. Update all the references in root tsconfig.json to ./packages/portal etc.
  4. Adjust build scripts, for changes check this commit

Setting up a simple socket server

Section commit here

First lets update the packages/server/index.ts file, new contents:

import express from 'express';
import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';

const app = express();
app.use(cors());

const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: [/http:\/\/localhost:\d*/],
  },
});

io.on('connection', (socket) => {
  console.log(
    `Socket ${socket.id} connected from origin: ${socket.handshake.headers.origin}`
  );
  socket.onAny((event, ...args) => {
    console.log(event, args);
  });
});

server.listen(5000, () => {
  console.log(
    `Server started on port ${5000} at ${new Date().toLocaleString()}`
  );
});
Enter fullscreen mode Exit fullscreen mode

We create a Socket.io server which we attach to our existing http server. On here we do some basic logging to log if someone connect and a onAny event handler that will log all events send to the server for debugging purposes.

Connecting the widget to the server

Section commit here

Now lets update the widget project to connect to the socket server. I am going to use Pinia to manage the state of both the widget and the portal. For the Widget we will have to add it as a dependency. You can do that by running:

yarn workspace widget add pinia
Enter fullscreen mode Exit fullscreen mode

in the root directory. This will add the dependency to the package.json inside the corresponding workspace.

Updating main.ts

Inside the widget entry let's add Pinia and refactor a bit. The new code will be:

import App from './App.vue';
import { createPinia } from 'pinia';
import { defineCustomElement, createApp } from 'vue';

const app = createApp(App);

app.use(createPinia());

const chatWidget = defineCustomElement(App);

customElements.define('chat-widget', chatWidget);
Enter fullscreen mode Exit fullscreen mode

This will define a custom element that we can use as <chat-widget /> inside regular HTML.

Adding a simple store

Create a file packages/widget/stores/main.ts, which will contain our main Pinia store, with the following content:

import { defineStore } from 'pinia';

export const useMainStore = defineStore('main', {
  state: () => ({
    hello: 'Hi there!',
  }),
  getters: {
    //
  },
  actions: {
    //
  },
});
Enter fullscreen mode Exit fullscreen mode

Creating App.vue

Inside the widget entry we imported App.vue, lets create it at packages/widget/App.vue with the following content:

<template>
  <div class="chat-widget">
    Chat-widget says hi!
    <div>From the store: {{ mainStore.hello }}</div>
  </div>
</template>

<script setup lang="ts">
import io from 'socket.io-client';
import { onUnmounted } from 'vue';
import { useMainStore } from './stores/main';

const URL = 'http://localhost:5000';
const socket = io(URL);
const mainStore = useMainStore();

socket.on('connect_error', (err) => {
  console.log('connection error', err);
});

socket.onAny((event, ...args) => {
  console.log(event, args);
});

onUnmounted(() => {
  socket.off('connect_error');
});
</script>

<style lang="scss">
.chat-widget {
  background-color: red;
  color: white;
}
</style>
Enter fullscreen mode Exit fullscreen mode

Connect the portal to the socket

Section commit here

Connecting the portal to the socket server is quite simple. We can leverage a Quasar feature called boot files for that. In short those are files that will run at application startup. You can initialize external packages in there instead of having one big entry file. Read more here

Create packages/portal/src/boot/socket.ts with the following content:

import { boot } from 'quasar/wrappers';
import io from 'socket.io-client';

export default boot(({}) => {
  const URL = 'http://localhost:5000';
  const socket = io(URL);
  socket.onAny((event, ...args) => {
    console.log(event, args);
  });
});
Enter fullscreen mode Exit fullscreen mode

And add socket to the boot section inside packages/portal/quasar.config.js. That is all!

Creating a simple chat between the portal and the widget

Now that we have everything connected properly, let's focus on some actual functionality. I'm going to highlight changes in here, all changes can be found in this git diff, spanning 4 commits:

Git diff here

Creating common type interfaces

I like to start with the basis, as we are using Typescript it makes sense to define the interfaces we are going to use. Most interfaces will be shared between all three projects, so I'm going to create a types.ts file in the root directory, and import from that inside the projects.

I'm not sure this is best practice, if there are other ideas/ways to do this, please let me know in the comments! We can always refactor πŸ˜‡

As an admin of the portal I want to see all connected clients and be able to chat with any one of them. Also I want to keep in mind that multiple admins could in theory chat with one client. Based on these requirements we will create the interfaces.

Create a types.ts file in the root directory with the following contents:

export interface AddClient {
  name: string;
}

export interface Client extends AddClient {
  id: string;
  connected: boolean;
  messages: Message[];
}

export interface Admin {
  name: string;
  connected?: boolean;
}

export enum MessageType {
  Admin = 'admin',
  Client = 'client',
  Info = 'info',
}

export interface Message {
  time: number;
  message: string;
  adminName?: Admin['name'];
  type: MessageType;
}
Enter fullscreen mode Exit fullscreen mode

This defines a basic structure of how a Message will look like.

  • A timestamp (unix time, so a number)
  • The message content
  • The type of a message
    • Admin if coming from the portal
    • Client if coming from the widget
    • Info if it is system message, like updated connection status etc.
  • The name of the admin, if it is a message of type Admin this will be filled

An array of these messages will be stored in an object we define as Client. Once a client connects we will supply some info about that client. For now that will only be a name, but this will be extended as we progress in this project.

Include this file in all the projects

If we want to import from types.ts which is at the root of the project from inside a package, we need to add some configuration to each package's tsconfig.json.

../../types.ts needs to be added to the include array, and "rootDir": "../../" added to the compilerOptions.

Add server code for admins and clients

The server will also have a few type interfaces of its own, not shared with the other packages. So we create packages/server/types.ts and define those types in there, as well as tunnel any types we use from the generic types as well:

import { Admin, Client, Message, AddClient } from '../../types';

export interface Database {
  clients: Client[];
  admins: Admin[];
}

export { Admin, Client, Message, AddClient };
Enter fullscreen mode Exit fullscreen mode

Next we will need to add socket handlers that will listen to events sent from either portal or widget and do something with those. To separate concerns I am going to create separate handlers for events send by admins and clients.

So let's create a file packages/server/handlers/adminHandler.ts:

import { Socket, Server } from 'socket.io';
import { Database, Message } from '../types';

export default function (io: Server, socket: Socket, db: Database) {
  socket.on('admin:add', (name: string) => {
    socket.join('admins');

    const admin = db.admins.find((admin) => admin.name === name);

    if (!admin) return socket.disconnect(true);
    admin.connected = true;

    socket.emit('admin:list', db.clients);

    socket.on(
      'admin:message',
      ({ id, message }: { id: string; message: Message }) => {
        const client = db.clients.find((client) => client.id === id);
        if (client) {
          // Store message in the DB
          client.messages.push(message);
          // Send message to the client
          socket.to(client.id).emit('client:message', message);
          // Send message to all admins
          io.to('admins').emit('admin:message', {
            id: client.id,
            message,
          });
        }
      }
    );

    socket.on('disconnect', () => {
      admin.connected = false;
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Quick (or not so quick) summary of what is going on here:

  1. This file returns a function which needs to be called with some parameters, including our database, which will just be a in memory javascript object for now.
  2. I will prefix messages between server and admin with admin:, so that I can more easily see what some event is about. This is just a convention I am going to use inside this project, not a requirement, you can name events however you want.
  3. Once an admin connects it will send a admin:add event to the server. Upon that event the server will add that admin to the room admins. > Rooms in Socket.io are used to easily send messages to multiple connected sockets.
  4. The database will contain some predefined admins. If the admin connecting is not among then, disconnect the socket. This is a first step into securing our server, but of course by no means secure yet. We will upgrade this as we go along.
  5. socket.emit('admin:list', db.clients); will emit the list of clients to the just connected admin.
  6. The admin:message event will listen for message send by the admin to a certain client.
    • This will contain the id of the client to which the message should go
    • It will lookup that client in the DB, and send the message to that client
    • After that it will send all admins that same message

Similarly we create a handler for the clients, packages/server/handlers/clientHandler.ts:

import { Socket, Server } from 'socket.io';
import { AddClient, Client, Database, Message } from '../types';

export default function (io: Server, socket: Socket, db: Database) {
  socket.on('client:add', (data: AddClient) => {
    socket.join('clients');
    const client: Client = {
      ...data,
      messages: [],
      id: socket.id,
      connected: true,
    };
    db.clients.push(client);
    io.to('admins').emit('admin:list', db.clients);

    socket.on('client:message', (message: Message) => {
      // Add message to DB
      client.messages.push(message);
      // Send message back to client
      socket.emit('client:message', message);
      // Send message to all admins
      io.to('admins').emit('admin:message', {
        id: client.id,
        message,
      });
    });

    socket.on('disconnect', () => {
      client.connected = false;
      io.to('admins').emit('admin:client_status', {
        id: client.id,
        status: false,
      });
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Summary of this file:

  1. All messages between client and server will be prefixed with client:
  2. When client sends client:add we join a room with all clients and add that client to the database.
  3. We notify all admins of the newly connected client with io.to('admins').emit('admin:list', db.clients);.
  4. When the client sends a message with the event client:message we:
    • Add that message to the database
    • Emit the message back to the client. This might seem odd but I want the messages that the client has in memory in the browser to have come from the server, so that we won't get in the situation that a client will see messages displayed that are not properly send over.
    • Emit the same message to all admins
  5. Upon disconnect of a client we will update the client status to all admins so that we can display the connection status in our list of clients.

Using these handlers and creating a database inside packages/server/index.ts it will look like this:

This is not all new code but I pasted the whole file for convenience.

import { createServer } from 'http';
import { Server } from 'socket.io';
import cors from 'cors';
import { Database } from './types';
import admins from './admins';
import adminHandler from './handlers/adminHandler';
import clientHandler from './handlers/clientHandler';

const app = express();
app.use(cors());
const server = createServer(app);
const io = new Server(server, {
  cors: {
    origin: [/http:\/\/localhost:\d*/],
  },
});

// Create an in memory 'database'
const db: Database = {
  clients: [],
  admins: admins,
};

io.on('connection', (socket) => {
  console.log(
    `Socket ${socket.id} connected from origin: ${socket.handshake.headers.origin}`
  );
  adminHandler(io, socket, db);
  clientHandler(io, socket, db);

  socket.onAny((event, ...args) => {
    console.log('[DEBUG]', event, args);
  });
});
Enter fullscreen mode Exit fullscreen mode

We import our handlers and call those functions when we receive a incoming connect, initializing all our event handlers. As for our 'database' this will be upgraded later on, for now I am ok with our clients being wiped on every restart of the server.

This file imports one file not yet mentioned, namely packages/server/admins.ts, which will function as our seed of admins:

import { Admin } from './types';

const admins: Admin[] = [
  {
    name: 'Evert',
  },
  {
    name: 'Jane Doe',
  },
];

export default admins;
Enter fullscreen mode Exit fullscreen mode

Defining a simple portal interface

In the portal packages I also delete quite some files, check the git diff to see which files are removed.

Inside the portal project I want to keep the data received from the server inside a separate Pinia store. So lets create packages/portal/src/stores/client.ts:

import { defineStore } from 'pinia';
import { Client, Message } from '../../../../types';

export const useClientStore = defineStore('client', {
  state: () => ({
    clients: [] as Client[],
    clientSelected: null as Client | null,
  }),
  actions: {
    SOCKET_list(payload: Client[]) {
      this.clients = payload;
    },
    SOCKET_message(payload: { id: string; message: Message }) {
      const client = this.clients.find((c) => c.id === payload.id);
      if (client) {
        client.messages.push(payload.message);
      }
    },
    SOCKET_client_status(payload: { id: string; status: boolean }) {
      const client = this.clients.find((c) => c.id === payload.id);
      if (client) {
        client.connected = payload.status;
      }
    },
    setClientSelected(payload: Client) {
      this.clientSelected = payload;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Quick summary:

  1. We store a list of clients and one selected client, the messages of the selected client will be displayed in the interface and we can switch between selected clients.
  2. Notice the prefix SOCKET_ for some actions, this signal events coming from the server. How this works I will explain later on.

The interface will consist of two main parts for now, a list to see which clients are connected and so select a client and a chat window, showing the messages of the selected client and an input to send a message to that client.

First the list, create packages/portal/src/components/ClientList.vue:

<template>
  <q-list>
    <q-item-label header> Client list </q-item-label>
    <q-item
      v-for="client in clientStore.clients"
      :key="client.id"
      v-ripple
      class="q-my-sm"
      clickable
      @click="clientStore.setClientSelected(client)"
    >
      <q-item-section avatar>
        <q-avatar color="primary" text-color="white"
          >{{ client.name.charAt(0) }}
        </q-avatar>
      </q-item-section>

      <q-item-section>
        <q-item-label>{{ client.name }}</q-item-label>
        <q-item-label caption lines="1">{{ client.id }}</q-item-label>
      </q-item-section>

      <q-item-section side>
        <q-badge rounded :color="client.connected ? 'green' : 'red'" />
      </q-item-section>
    </q-item>
  </q-list>
</template>

<script setup lang="ts">
import { useClientStore } from 'src/stores/client';
const clientStore = useClientStore();
</script>

<style lang="scss"></style>
Enter fullscreen mode Exit fullscreen mode

Quasar has quite some components to create easy, good looking lists with, with lots of customizations possible, see the documentation for more information. We just loop over the list of clients and display an item for each client. For that client we display the name and connection status using a green or red dot.

For the display of message we create packages/portal/src/components/ClientChat.vue:

<template>
  <div v-if="clientStore.clientSelected" class="fit column">
    <div class="text-h6 q-pa-md">
      Chat with {{ clientStore.clientSelected.name }}
    </div>
    <q-separator></q-separator>
    <div class="col q-pa-md">
      <div
        v-for="(message, index) in clientStore.clientSelected.messages"
        :key="index"
      >
        {{ message.message }}
      </div>
    </div>
    <div class="q-pa-md row items-center">
      <q-input
        v-model="text"
        outlined
        placeholder="Type your message here"
        class="col"
      />
      <div class="q-pl-md">
        <q-btn
          outline
          round
          icon="send"
          :disabled="!text"
          @click="sendMessage"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useClientStore } from 'src/stores/client';
import { socket } from 'src/boot/socket';
import { Message, MessageType } from '../../../../types';
const clientStore = useClientStore();
const text = ref('');
function sendMessage() {
  if (clientStore.clientSelected) {
    const message: Message = {
      time: Date.now(),
      message: text.value,
      type: MessageType.Admin,
    };
    socket.emit('admin:message', {
      id: clientStore.clientSelected.id,
      message,
    });
    text.value = '';
  }
}
</script>

<style lang="scss"></style>
Enter fullscreen mode Exit fullscreen mode

Which will just display the messages in plain text, no styling for now. There is also an input along with a button to input some text which we can send to the server upon clicking the button. Again we use some Quasar components for the button and the input.

Now we have to use these components, so we edit packages/portal/src/layouts/MainLayout.vue to:

<template>
  <q-layout view="lHh Lpr lFf">
    <q-header elevated>
      <q-toolbar>
        <q-btn
          flat
          dense
          round
          icon="menu"
          aria-label="Menu"
          @click="toggleLeftDrawer"
        />

        <q-toolbar-title> Quasar App </q-toolbar-title>

        <div>Quasar v{{ $q.version }}</div>
      </q-toolbar>
    </q-header>

    <q-drawer v-model="leftDrawerOpen" show-if-above bordered>
      <ClientList />
    </q-drawer>

    <q-page-container>
      <router-view />
    </q-page-container>
  </q-layout>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import ClientList from 'src/components/ClientList.vue';

const leftDrawerOpen = ref(false);

function toggleLeftDrawer() {
  leftDrawerOpen.value = !leftDrawerOpen.value;
}
</script>
Enter fullscreen mode Exit fullscreen mode

And the packages/portal/src/pages/IndexPage.vue:

<template>
  <q-page :style-fn="fullPage">
    <ClientChat />
  </q-page>
</template>

<script setup lang="ts">
import ClientChat from 'src/components/ClientChat.vue';

function fullPage(offset: number) {
  return { height: offset ? `calc(100vh - ${offset}px)` : '100vh' };
}
</script>
Enter fullscreen mode Exit fullscreen mode

Now that we have that setup we have to make sure that events are send to the socket instance at the portal make it to our store actions, and update the store. To do this, we can make use of the onAny listener that SocketIO provides, we update packages/portal/src/boot/socket.ts:

import { boot } from 'quasar/wrappers';
import io from 'socket.io-client';
import { useClientStore } from 'src/stores/client';

const URL = 'http://localhost:5000';
const socket = io(URL);

export default boot(({ store }) => {
  const clientStore = useClientStore(store);
  socket.emit('admin:add', 'Evert');
  socket.onAny((event: string, ...args) => {
    if (event.startsWith('admin:')) {
      const eventName = event.slice(6);
      if (Object.hasOwn(clientStore, 'SOCKET_' + eventName)) {
        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
        // @ts-expect-error
        clientStore['SOCKET_' + eventName](...args);
      }
    }
    console.log(`[DEBUG] ${event}`, args);
  });
});

export { socket };
Enter fullscreen mode Exit fullscreen mode

What is happening here?

  1. We emit the admin:add event to add ourselves to the admin pool. We have to add authentication here later of course as now anyone can do that.
  2. In the onAny event we parse the event name, and if it starts with admin: we take the part after it and check if there is a store action defined called SOCKET_ + that part after it. If there is we call that action with the arguments passed in by the events. This way we only have to add the specific actions in the store if we want to process more events, no additional socket listening needed, I'm quite happy with that.πŸ˜„

The last change to the portal package is to set the router mode of vue-router to history instead of the default hash used by Quasar. We do this by setting the vueRouterMode property in the quasar.config.js to history.

Setting up the widget

Now that we have the server and portal done, we can move on to the widget. In here we will have to emit the event client:add and supply client details. Instead of coming up with weird names myself I am going to use a package called faker, to do this for me for the remainder of this series. We have to add that to our widget package:

yarn workspace widget add @faker-js/faker
Enter fullscreen mode Exit fullscreen mode

This command must be run from the root folder, and it will add a dependency to the package.json inside the packages/widget folder.

Inside the widget package we already have 1 store defined, this will hold our UI state, the socket/client data I will put in a separate store, so lets create packages/widget/src/stores/socket.ts:

import { defineStore } from 'pinia';
import { Message } from '../../../../types';

export const useSocketStore = defineStore('socket', {
  state: () => ({
    messages: [] as Message[],
  }),
  actions: {
    SOCKET_message(payload: Message) {
      this.messages.push(payload);
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

As you can see we are going to use the same action prefix as inside the portal package. Only thing left is to update our packages/widget/src/App.vue and add some code to display and send messages in here:

<template>
  <div class="chat-widget">
    Chat-widget
    <div>Name: {{ name }}</div>
    Messages:
    <div class="messages">
      <div v-for="(message, index) in socketStore.messages" :key="index">
        {{ message.message }}
      </div>
    </div>
    <input v-model="text" type="text" />
    <button @click="sendMessage">Send</button>
  </div>
</template>

<script setup lang="ts">
import io from 'socket.io-client';
import { onUnmounted, ref } from 'vue';
import { useSocketStore } from './stores/socket';
import { AddClient, Message, MessageType } from '../../../types';
import faker from '@faker-js/faker/locale/en';

const URL = 'http://localhost:5000';
const socket = io(URL);
const socketStore = useSocketStore();
const name = faker.name.firstName();
const text = ref('');

const addClient: AddClient = {
  name,
};

socket.emit('client:add', addClient);
socket.onAny((event: string, ...args) => {
  if (event.startsWith('client:')) {
    const eventName = event.slice(7);
    if (Object.hasOwn(socketStore, 'SOCKET_' + eventName)) {
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-expect-error
      socketStore['SOCKET_' + eventName](...args);
    }
  }
  console.log(`[DEBUG] ${event}`, args);
});

function sendMessage() {
  const message: Message = {
    time: Date.now(),
    message: text.value,
    type: MessageType.Client,
  };
  socket.emit('client:message', message);
  text.value = '';
}

onUnmounted(() => {
  socket.off('connect_error');
});
</script>

<style lang="scss">
.chat-widget {
  background-color: #eeeeee;
  color: #111111;
}

.messages {
  padding: 16px;
}
</style>

Enter fullscreen mode Exit fullscreen mode

And thats it! You should have a basic setup functioning now, where you can send/receive messages between a widget and a portal.

Here is a small gif of things in action:

Demo of the project so far

Wrapping up

We have the basics setup up now, but there is still much to do to extend it, what is currently on my list of things to include in this series (not necessarely in that order):

  • Persist the database between restarts
  • Add authentication for the portal
  • Add authentication for admins connecting to the server
  • Display when a client/admin is typing
  • Setting up a pipeline for automatic deployment
  • Add avatars
  • Group/cluster the chat messages and show timestamps

I will keep off from styling everything in detail for now. In part because I don't have a good design for it yet, and also because everyone will probably want their own design, so I'm just gonna focus on the technical stuff.

Until next time! Thanks for making it so far πŸ‘

Top comments (0)