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 π
- Create a new folder
packages
in the root directory. Move theserver
,portal
andwidget
folders in here. - Update workspaces in root
package.json
to["packages/*"]
- Update all the references in root
tsconfig.json
to./packages/portal
etc. - 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()}`
);
});
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
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);
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: {
//
},
});
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>
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);
});
});
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;
}
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 };
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;
});
});
}
Quick (or not so quick) summary of what is going on here:
- 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. - I will prefix messages between
server
andadmin
withadmin:
, 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. - 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 roomadmins
. > Rooms in Socket.io are used to easily send messages to multiple connected sockets. - 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.
-
socket.emit('admin:list', db.clients);
will emit the list of clients to the just connected admin. - 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
- This will contain the
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,
});
});
});
}
Summary of this file:
- All messages between
client
andserver
will be prefixed withclient:
- When client sends
client:add
we join a room with all clients and add that client to the database. - We notify all admins of the newly connected client with
io.to('admins').emit('admin:list', db.clients);
. - 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
- 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);
});
});
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;
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;
},
},
});
Quick summary:
- 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.
- Notice the prefix
SOCKET_
for some actions, this signal events coming from theserver
. 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>
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>
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>
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>
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 };
What is happening here?
- 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. - In the
onAny
event we parse the event name, and if it starts withadmin:
we take the part after it and check if there is a store action defined calledSOCKET_
+ 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
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);
},
},
});
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>
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:
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)