Part 2: Connecting everything together
Tags: javascript, webdev, tutorial, vue
Published: 2022-04-23 19:17:01

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](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/commit/7593d69ee23fef3b3e3d0e981bfee78c6afbc820)

##Setting up a simple socket server
> Section commit [here](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/commit/b0db6c56f583d0977b82b2f26115fced10a48b13)

First lets update the `packages/server/index.ts` file, new contents:
```ts
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](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/commit/0873d73a8132ef97fa7caec21a3c10a2715ce45c)

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:
```bash
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:
```ts
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:
```ts
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:
```vue
<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](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/commit/3985cb54bfadc3a637466d72f6220dea6350b954) 

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](https://quasar.dev/quasar-cli-vite/boot-files#introduction)

Create `packages/portal/src/boot/socket.ts` with the following content:
```ts
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](https://github.com/Evertvdw/creating-a-embeddable-chat-widget/compare/779184f..db2598f?diff=split)

### 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:
```ts
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:

```ts
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`:
```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:
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`:
```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:
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.

```ts
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:
```ts
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`:

```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:
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`:
```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](https://quasar.dev/vue-components/list-and-list-items#introduction) 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`:
```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](https://quasar.dev/vue-components/button#introduction) and the [input](https://quasar.dev/vue-components/input#introduction).

Now we have to use these components, so we edit `packages/portal/src/layouts/MainLayout.vue` to:
```vue
<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`:
```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`:

```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?**
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:

```bash
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`:

```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:
```vue
<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:

![Demo of the project so far](https://s7.gifyu.com/images/dev-to-series-part-2.gif)

## 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 👍