{"id":18230503,"url":"https://github.com/benc-uk/chatr","last_synced_at":"2025-04-14T16:11:45.882Z","repository":{"id":46129505,"uuid":"363868496","full_name":"benc-uk/chatr","owner":"benc-uk","description":"Chat app using Azure Web PubSub, Static Web Apps and other Azure services","archived":false,"fork":false,"pushed_at":"2024-07-10T06:54:51.000Z","size":4710,"stargazers_count":70,"open_issues_count":1,"forks_count":27,"subscribers_count":4,"default_branch":"main","last_synced_at":"2024-12-31T18:16:52.953Z","etag":null,"topics":["azure","rest-api","static-site","websockets"],"latest_commit_sha":null,"homepage":"https://code.benco.io/chatr/","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/benc-uk.png","metadata":{"files":{"readme":"readme.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2021-05-03T08:45:29.000Z","updated_at":"2024-04-11T17:26:04.000Z","dependencies_parsed_at":"2022-09-11T15:31:32.645Z","dependency_job_id":null,"html_url":"https://github.com/benc-uk/chatr","commit_stats":null,"previous_names":[],"tags_count":4,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/benc-uk%2Fchatr","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/benc-uk%2Fchatr/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/benc-uk%2Fchatr/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/benc-uk%2Fchatr/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/benc-uk","download_url":"https://codeload.github.com/benc-uk/chatr/tar.gz/refs/heads/main","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":232924642,"owners_count":18597578,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["azure","rest-api","static-site","websockets"],"created_at":"2024-11-04T11:04:23.858Z","updated_at":"2025-01-07T19:12:23.721Z","avatar_url":"https://github.com/benc-uk.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Chatr - Azure Web PubSub Sample App\n\nThis is a demonstration \u0026 sample application designed to be a simple multi-user web based chat system.  \nIt provides persistent group chats, user to user private chats, a user list, idle (away from keyboard) detection and several other features.\n\nIt is built on several Azure technologies, including: _Web PubSub, Static Web Apps_ and _Table Storage_\n\n\u003e 👁‍🗨 Note. This was created as a personal project, created to aid learning while building something interesting. The code comes with all the caveats you might expect from such a project.\n\n![](https://img.shields.io/github/license/benc-uk/chatr)\n![](https://img.shields.io/github/last-commit/benc-uk/chatr)\n![](https://img.shields.io/github/checks-status/benc-uk/chatr/main)\n![](https://img.shields.io/github/workflow/status/benc-uk/chatr/Azure%20Static%20Web%20Apps%20Deploy?label=client-deploy)\n\nGoals:\n\n- Learn about using websockets\n- Write a 'fun' thing\n- Try out the new _Azure Web PubSub_ service\n- Use the authentication features of _Azure Static Web Apps_\n- Deploy everything using _Azure Bicep_\n\nUse cases \u0026 key features:\n\n- Sign-in with Microsoft, Twitter or GitHub accounts\n- Realtime chat with users\n- Shared group chats, only the creator can remove the chat\n- Detects where users are idle and away from keyboard (default is one minute)\n- Private 'user to user' chats, with notifications and popups\n\n# Screenshot\n\n![](./etc/screen.png)\n\n# Architecture\n\n![](./etc/diagram.png)\n\n# Client / Frontend\n\nThis is the main web frontend as used by end users via the browser.\n\nThe source for this is found in **client/** and consists of a static standalone pure ES6 JS application, no bundling or Node.js is required. It is written using [Vue.js as a supporting framework](https://vuejs.org/), and [Bulma as a CSS framework](https://bulma.io/).\n\nSome notes:\n\n- ES6 modules are used so the various JS files can use import/export without the need to bundle.\n- Vue.js is used as a browser side library loaded from CDN as a ESM module, this is an elegant \u0026 lightweight approach supported by modern browsers, rather than the usual vue-cli style app which requires Node and webpack etc.\n- `client/js/app.js` shows how to create a Vue.js app with child components using this approach. The majority of client logic is here.\n- `client/js/components/chat.js` is a Vue.js component used to host each chat tab in the application\n- The special `.auth/` endpoint provided by Static Web Apps is used to sign users in and fetch their user details, such as userId.\n\n# Server\n\nThis is the backend, handling websocket events to and from Azure Web PubSub, and providing REST API for some operations.\n\nThe source for this is found in **api/** and consists of a Node.js Azure Function App. It connects to Azure Table Storage to persist group chat and user data (Table Storage was picked as it's simple \u0026 cheap). This is not hosted in a standalone Azure Function App but instead [deployed into the Static Web App as part of it's serverless API support](https://docs.microsoft.com/en-us/azure/static-web-apps/apis)\n\nThere are four HTTP functions all served from the default `/api/` path\n\n- `eventHandler` - Webhook receiver for \"upstream\" events sent from Azure Web PubSub service, contains the majority of application logic. Not called directly by the client, only Azure WebPub Sub.\n- `getToken` - Called by the client to get an access token and URL to connect via WebSockets to the Azure Web PubSub service. Must be called with userId in the URL query, e.g. GET `/api/getToken?userId={user}`\n- `getUsers` - Returns a list of signed in users, note the route for this function is `/api/users`\n- `getChats` - Returns a list of active group chats, note the route for this function is `/api/chats`\n\nState is handled with `state.js` which is an ES6 module exporting functions supporting state CRUD for users and chats. This module carries out all the interaction with Azure Tables, and provides a relatively transparent interface, so a different storage backend could be swapped in.\n\n## WebSocket \u0026 API Message Flows\n\nThere is two way message flow between clients and the server via [Azure Web PubSub and event handlers](https://azure.github.io/azure-webpubsub/concepts/service-internals#event-handler)\n\n[The json.webpubsub.azure.v1 subprotocol is used](https://azure.github.io/azure-webpubsub/references/pubsub-websocket-subprotocol) rather than basic WebSockets, this provides a number of features: users can be added to groups, clients can send custom events (using `type: event`), and also send messages direct to other clients without going via the server (using `type: sendToGroup`)\n\nNotes:\n\n- Chat IDs are simply randomly generated GUIDs, these correspond to \"groups\" in the subprotocol.\n- Private chats are a special case, they are not persisted in state, and they do not trigger **chatCreated** events. Also the user doesn't issue a **joinChat** event to join them, that is handled by the server as a kind of \"push\" to the clients.\n- User IDs are simply strings which are considered to be unique, this could be improved, e.g. with prefixing.\n\n### Client Messaging\n\nEvents \u0026 chat are sent using the _json.webpubsub.azure.v1_ subprotocol\n\nChat messages sent from the client use `sendToGroup` and a custom JSON payload with three fields `message`, `fromUserId` \u0026 `fromUserName`, these messages are relayed client to client by Azure Web PubSub, the server is never notified of them:\n\n```go\n{\n  type: 'sendToGroup',\n  group: \u003cchatId\u003e,\n  dataType: 'json',\n  data: {\n    message: \u003cmessage text\u003e,\n    fromUserId: \u003cuserId\u003e,\n    fromUserName: \u003cuserName\u003e,\n  },\n}\n```\n\nEvents destined for the backend server are sent as WebSocket messages from the client via the same subprotocol with the `event` type, and an application specific sub-type, e.g.\n\n```go\n{\n  type: 'event',\n  event: 'joinChat',\n  dataType: 'text',\n  data: \u003cchatId\u003e,\n}\n```        \n\nThe types of events are:\n\n- **createChat** - Request the server you want to create a group chat\n- **createPrivateChat** - Request the server you want to create a private chat\n- **joinChat** - To join a chat, the server will add user to the group for that chatId\n- **leaveChat** - To leave a group chat\n- **deleteChat** - Called from a chat owner to delete a chat\n- **userEnterIdle** - Let the server know user is now idle\n- **userExitIdle** - Let the server know user is no longer idle\n\nThe backend API `eventHandler` function has cases for each of these user events, along with handlers for connection \u0026 disconnection system events.\n\n### Server Messaging\n\nMessages sent from the server have a custom Chatr app specific payload as follows:\n\n```go\n{\n  chatEvent: \u003ceventType\u003e,\n  data: \u003cJSON object type dependant\u003e\n}\n```\n\nWhere `eventType` is one of:\n\n- **chatCreated** - Let all users know a new group chat has been created\n- **chatDeleted** - Let all users know a group chat has been removed\n- **userOnline** - Let all users know a user has come online\n- **userOffline** - Let all users know a user has left\n- **joinPrivateChat** - Sent to both the initiator and recipient of a private chat\n- **userIsIdle** - Sent to all users when a user enters idle state\n- **userNotIdle** - Sent to all users when a user exits idle state\n\nThe client code in `client/js/app.js` handles these messages as they are received by the client, and reacts accordingly.\n\n# Some Notes on Design and Service Choice\n\nThe plan of this project was to use _Azure Web PubSub_ and _Azure Static Web Apps_, and to host the server side component as a set of serverless functions in the _Static Web Apps_ API support (which is in fact _Azure Functions_ under the hood). _Azure Static Web Apps_ was selected because it has [amazing support for codeless and config-less user sign-in and auth](https://docs.microsoft.com/en-us/azure/static-web-apps/authentication-authorization), which I wanted to leverage.\n\nSome comments on this approach:\n\n- [API support in _Static Web Apps_ is quite limited](https://docs.microsoft.com/en-us/azure/static-web-apps/apis) and can't support the new bindings and triggers for Web PubSub. **HOWEVER** You don't need to use these bindings 😂. You can create a standard HTTP function to act as a webhook event handler instead of using the `webPubSubConnection` binding. For sending messages back to Web PubSub, the server SDK can simply be used within the function code rather than using the `webPubSub` output binding.\n- Table Storage was picked for persisting state as it has a good JS SDK (the new SDK in @azure/data-table was used), it's extremely lightweight and cheap and was good enough for this project, see deails below\n\n# State \u0026 Entity Design\n\nState in Azure Tables consists of two tables (collections) named `chats` and `users`\n\n### Chats Table\n\nAs each chat contains nested objects inside the members field, each chat is stored as a JSON string in a field called `data`. The PartitionKey is not used and hardcoded to a string \"chatr\". The RowKey and the id field inside the data object are the same.\n\n- **PartitionKey**: \"chatr\"\n- **RowKey**: The chatId (random GUID created client side)\n- **data**: JSON stringified chat entity\n\nExample of a chat data entity\n\n```json\n{\n  \"id\": \"eab4b030-1a3d-499a-bd89-191578395910\",\n  \"name\": \"This is a group chat\",\n  \"members\": {\n    \"0987654321\": {\n      \"userId\": \"0987654321\",\n      \"userName\": \"Another Guy\"\n    },\n    \"1234567890\": {\n      \"userId\": \"1234567890\",\n      \"userName\": \"Ben\"\n    }\n  },\n  \"owner\": \"1234567890\"\n}\n```\n\n### Users Table\n\nUsers are stored as entities with the fields (columns) described below. As there are no nested fields, there is no need to encode as a JSON string. Again the PartitionKey is not used and hardcoded to a string \"chatr\".\n\n- **PartitionKey**: \"chatr\"\n- **RowKey**: The `userId` field returned from Static Web Apps auth endpoint\n- **userName**: The username (could be email address or handle) of the user\n- **userProvider**: Which auth provided the user signed in with `twitter`, `aad` or `github`\n- **idle**: Boolean, indicating if the user us currently idle\n\n# Running and Deploying the App\n\n## Working Locally\n\nSee makefile\n\n```text\n$ make\nhelp                 💬 This help message\nlint                 🔎 Lint \u0026 format, will not fix but sets exit code on error\nlint-fix             📜 Lint \u0026 format, will try to fix errors and modify code\nrun                  🏃 Run server locally using Static Web Apps CLI\nclean                🧹 Clean up project\ndeploy               🚀 Deploy everything to Azure using Bicep\ntunnel               🚇 Start loophole tunnel to expose localhost\n```\n\n## Deploying to Azure\n\nDeployment is slightly complex due to the number of components and the configuration between them. The makefile target `deploy` should deploy everything for you in a single step using Bicep templates found in the **deploy/** folder\n\n[See readme in deploy folder for details and instructions](./deploy)\n\n## Running Locally\n\nThis is possible but requires a little effort as the Azure Web PubSub service needs to be able call the HTTP endpoint on your location machine, so a tunnel has employed.\n\nWhen running locally the Static Web Apps CLI is used and this provides a fake user authentication endpoint for us.\n\nA summary of the steps is:\n\n- Deploy an _Azure Storage_ account, get name and access key.\n- Deploy an _Azure Web Pub Sub_ instance, get connection string from the 'Keys' page.\n- Copy `api/local.settings.sample.json` to `api/local.settings.json` and edit the required settings values.\n- Start a localhost tunnel service such as **ngrok** or **loophole**. The tunnel should expose port 7071 over HTTP.  \n  I use [loophole](https://loophole.cloud/) as it allows me to set a custom host \u0026 DNS name, e.g.\n  - `loophole http 7071 --hostname chatr`\n- In _Azure Web Pub Sub_ settings.\n  - Add a hub named **chat**\n  - In the URL template put `https://{{hostname-of-tunnel-service}}/api/eventHandler`\n  - In system events tick **connected** and **disconnected**\n- Run `make run`\n- Open `http://localhost:4280/index.html`\n\n# Known Issues\n\n- Won't run in Firefox as top level await is not yet supported\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbenc-uk%2Fchatr","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbenc-uk%2Fchatr","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbenc-uk%2Fchatr/lists"}