Introduction
In this part of the series, we will implement our backend service's WebSocket handler and begin building the frontend. This will enable real-time communication between the backend and frontend, allowing us to display analysis results as they become available.
Prerequisite
The main prerequisite is that you have gone through the previous articles in this series. This ensures you have the necessary context and environment set up.
Source code
An AI-powered financial behavior analyzer and advisor written in Python (aiohttp) and TypeScript (ExpressJS & SvelteKit with Svelte 5)
Implementation
Step: WebSocket handler
To provide real-time updates for financial data analysis and summaries, we use WebSockets, a bidirectional communication protocol, instead of traditional HTTP requests that require constant refreshing. The TransactionWebSocketHandler
function manages WebSocket connections:
import { WebSocket } from "ws";
import { TransactionService } from "$services/transaction.service.js";
import { baseConfig } from "$config/base.config.js";
import mongoose from "mongoose";
import { sendError } from "$utils/error.utils.js";
export function TransactionWebSocketHandler(ws: WebSocket): void {
ws.on("message", async (message: string) => {
try {
const actions = JSON.parse(message);
if (!Array.isArray(actions)) {
sendError(ws, "Invalid message format. Expected an array.");
return;
}
for (const actionObj of actions) {
if (!actionObj.action || !actionObj.userId) {
sendError(
ws,
"Invalid action format. Each action requires 'action' and 'userId'."
);
return;
}
const { action, userId } = actionObj;
if (!mongoose.Types.ObjectId.isValid(userId)) {
sendError(ws, "Invalid userId format.");
return;
}
switch (action) {
case "analyze":
case "summary":
await handleAction(ws, new mongoose.Types.ObjectId(userId), action);
break;
default:
sendError(ws, `Unknown action: ${action}`);
}
}
} catch (error) {
baseConfig.logger.error(
`Error processing message: ${
error instanceof Error ? error.message : error
}`
);
sendError(
ws,
`Failed to process message: ${
error instanceof Error ? error.message : error
}`
);
}
});
ws.on("close", () => {
baseConfig.logger.info("Frontend WebSocket connection closed");
});
ws.on("error", (error) => {
baseConfig.logger.error(`WebSocket error: ${error.message}`);
});
}
async function handleAction(
frontendWs: WebSocket,
userId: mongoose.Types.ObjectId,
action: string
) {
try {
const transactions = await TransactionService.findTransactionsByUserId(
userId,
-1,
-1
);
if (!transactions) {
sendError(
frontendWs,
`No transactions found for userId: ${userId}`,
action
);
return;
}
await TransactionService.connectToUtilityServer(
action,
transactions.transactions,
frontendWs
);
} catch (error) {
baseConfig.logger.error(
`Error handling action: ${error instanceof Error ? error.message : error}`
);
sendError(
frontendWs,
`Failed to handle action: ${
error instanceof Error ? error.message : error
}`
);
}
}
Using the ws
module (installed in the previous article), this function sets up the WebSocket connection and defines handlers for incoming messages, connection close, and errors. As soon as it receives a message, the callback for the message
event listener gets fired and it works as follows:
- Parses incoming messages as JSON, expecting an array of actions.
- Validates the format of each action, ensuring it contains
action
anduserId
. - Validates the
userId
to ensure it is a valid Mongoose ObjectId. - Uses a
switch
statement to handle different actions (analyze
andsummary
), and - Calls the
handleAction
function to process each valid action.
The handleAction
processes specific actions requested by the client by retrieving transactions for the specified userId
using TransactionService.findTransactionsByUserId
static method discussed in the previous article. Any transactions retrieved are sent via the TransactionService.connectToUtilityServer
method to the utility server for analysis or summary.
Let's register this handler with our app:
...
import { WebSocketServer } from "ws";
...
import { TransactionWebSocketHandler } from "$websockets/transaction.websocket.js";
...
const startServer = async () => {
try {
const server: HttpServer = createServer(app);
const wss = new WebSocketServer({ server, path: "/ws" });
// 5. Setup WebSocket handlers
wss.on("connection", (ws) => {
TransactionWebSocketHandler(ws);
});
// 6. Connect to MongoDB
baseConfig.logger.info("Connecting to MongoDB cluster...");
const db = await connectToCluster();
...
} catch (error) {
baseConfig.logger.error("Error starting server:", error);
process.exit(1);
}
};
startServer();
With that, we conclude the backend service. Now it's time to set SvelteKit up with TailwindCSS.
Step 2: SvelteKit with TailwindCSS
To setup a SvelteKit project with TailwindCSS, we will refer to the official TailwindCSS guide with some modifications from the migration guide I previously wrote.
First, create a new SvelteKit project via the Svelte 5 sv
CLI:
$ npx sv create interface # modify the name as you please
You will be prompted to install sv
which you should accent to. Your interaction with the CLI should look like this:
projects npx sv create interface
Need to install the following packages:
sv@0.6.18
Ok to proceed? (y) y
┌ Welcome to the Svelte CLI! (v0.6.18)
│
◇ Which template would you like?
│ SvelteKit minimal
│
◇ Add type checking with Typescript?
│ Yes, using Typescript syntax
│
◆ Project created
│
◇ What would you like to add to your project? (use arrow keys / space bar)
│ prettier, eslint, vitest, tailwindcss, sveltekit-adapter
│
◇ tailwindcss: Which plugins would you like to add?
│ typography, forms
│
◇ sveltekit-adapter: Which SvelteKit adapter would you like to use?
│ node
│
◇ Which package manager do you want to install dependencies with?
│ npm
│
◆ Successfully setup add-ons
│
◆ Successfully installed dependencies
│
◇ Successfully formatted modified files
│
◇ Project next steps ─────────────────────────────────────────────────────╮
│ │
│ 1: cd interface │
│ 2: git init && git add -A && git commit -m "Initial commit" (optional) │
│ 3: npm run dev -- --open │
│ │
│ To close the dev server, hit Ctrl-C │
│ │
│ Stuck? Visit us at https://svelte.dev/chat │
│ │
├──────────────────────────────────────────────────────────────────────────╯
│
└ You're all set!
Feel free to modify any of the steps as you like. You can change directory into the newly created project and install the dependencies.
For some reasons, the tailwindcss installed by sv
was version 3.4.17
. However, at the time of writting this, TailwindCSS is already at version 4.0.2
. So we need to migrate. To incept the migration steps, run this command:
interface$ npx @tailwindcss/upgrade@next
You should get something like this:
interface$ npx @tailwindcss/upgrade@next
Need to install the following packages:
@tailwindcss/upgrade@4.0.2
Ok to proceed? (y) y
≈ tailwindcss v4.0.2
fatal: not a git repository (or any of the parent directories): .git
│ Searching for CSS files in the current
│ directory and its subdirectories…
│ ↳ Linked `./tailwind.config.ts` to
│ `./src/app.css`
│ Migrating JavaScript configuration files…
│ ↳ Migrated configuration file:
│ `./tailwind.config.ts`
│ Migrating templates…
│ ↳ Migrated templates for configuration file:
│ `./tailwind.config.ts`
│ Migrating stylesheets…
│ ↳ Migrated stylesheet: `./src/app.css`
│ Migrating PostCSS configuration…
│ ↳ Installed package: `@tailwindcss/postcss`
│ ↳ Removed package: `autoprefixer`
│ ↳ Migrated PostCSS configuration:
│ `./postcss.config.js`
│ Updating dependencies…
│ ↳ Updated package:
│ `prettier-plugin-tailwindcss`
│ ↳ Updated package: `tailwindcss`
fatal: not a git repository (or any of the parent directories): .git
│ No changes were made to your repository.
It will magically modify your src/app.css
to look like:
@import "tailwindcss";
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
tailwind.config.ts
will be removed and postcss.config.js
will be modified. We need to make a slight change to src/app.css
and vite.config.js
:
@import "tailwindcss";
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
@custom-variant dark (&:where(.dark, .dark *));
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
We need line 6 so that we can leverage classes (and later, prefers-color-scheme
) to dynamically switch themes.
Next is vite.config.js
:
import { defineConfig } from "vitest/config";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss(), sveltekit()],
test: {
include: ["src/**/*.{test,spec}.{js,ts}"],
},
});
That concludes the initial setup.
Step 3: Leverage `prefers-color-scheme` in theme toggling
Many modern operating systems (OS) have made dark mode a first-class feature, and tuning your web application to honor the user's OS theme preference provides a better user experience. The CSS prefers-color-scheme
media feature makes this easy to implement. Here's how to leverage it:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="language" content="en" />
<!-- Theme -->
<meta
name="theme-color"
content="#ffffff"
media="(prefers-color-scheme: light)"
/>
<meta
name="theme-color"
content="#111827"
media="(prefers-color-scheme: dark)"
/>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@300..700&family=Fira+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"
rel="stylesheet"
/>
... %sveltekit.head%
<script>
// Get user's explicit preference
const userTheme = localStorage.getItem("theme");
// Get system preference
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
// Use user preference if set, otherwise follow system
const theme = userTheme || (systemTheme.matches ? "dark" : "light");
// Apply theme
document.documentElement.classList.toggle("dark", theme === "dark");
// Listen for system changes
systemTheme.addEventListener("change", (e) => {
// Only follow system if user hasn't set preference
if (!localStorage.getItem("theme")) {
document.documentElement.classList.toggle("dark", e.matches);
}
});
</script>
</head>
<body
data-sveltekit-preload-data="hover"
class="bg-white text-black dark:bg-gray-900 dark:text-white"
>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
This short HTML code does a lot of things. First, the theme-color
meta tags adapt the browser's UI (e.g., address bar) to match the theme. prefers-color-scheme
is used to set different colors for light and dark modes. Then, in the script
, we first checks if the user has a saved theme preference in localStorage
. If not, it checks the OS-level dark mode setting using window.matchMedia('(prefers-color-scheme: dark)')
. It then applies the appropriate theme by adding or removing the dark
class to the <html>
element. This class is used to toggle CSS styles (e.g., using dark:bg-gray-900
in the <body>
class). Finally, it listens for changes in the OS-level dark mode setting and updates the theme accordingly (but only if the user hasn't explicitly set a preference). We also used the <body>
element to set the background and text colors based on the presence of the dark class using Tailwind CSS classes.
Step 4: Theme switching logic with icons
Before we proceed to creating the authentication/login page, let's make a simple ThemeSwitcher.svelte
component:
<script lang="ts">
import Moon from "$lib/components/icons/Moon.svelte";
import Sun from "$lib/components/icons/Sun.svelte";
let { ...props } = $props();
let isDark = $state(false);
$effect(() => {
isDark = document.documentElement.classList.contains("dark");
});
function toggleTheme() {
isDark = !isDark;
localStorage.setItem("theme", isDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", isDark);
}
</script>
<button onclick="{toggleTheme}" {...props}>
{#if isDark}
<Sun />
{:else}
<Moon />
{/if}
</button>
Since this app will be fully powered by Svelte 5, we are using the $props()
rune to accept any attributes passed to the component as props, and the spread operator helps expand these attributes. We also declared a reactive variable with the $state
rune, and it gets updated in the $effect
rune and in the toggleTheme
function. The $effect
rune runs once on component initialization and whenever its dependencies change. In this case, it checks if the dark
class is present on the <html>
element and updates the isDark
state accordingly. This ensures the component's initial state matches the current theme. As for the toggleTheme
function, it gets called when the button is clicked and toggles the isDark
state, saves the selected theme ("dark" or "light") to localStorage
, and toggles the dark
class on the <html>
element. The <button>
element calls the toggleTheme
function on click and passes any additional props to the button. Inside the button, the {#if isDark}...{:else}...{/if}
block conditionally renders either the Sun
or Moon
component based on the isDark
state.
The on:
directive was used in Svelte 4 to attach events to HTML elements. However, Svelte 5 changed that narrative by being more natural with HTML, which sees events as simply properties.
The sun and moon icons are simply svgs that have become svelte components:
<script lang="ts">
let { ...props } = $props();
</script>
<svg
class="h-6 w-6 text-yellow-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<script lang="ts">
let { ...props } = $props();
</script>
<svg
class="h-6 w-6 text-gray-700 dark:text-gray-200"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
This is a nifty way to make svgs flexible as you can pass styles are other attributes and they will be reflected.
Step 5: Authentication or login page
Now, let's see what the login page will look like:
<script lang="ts">
import { page } from '$app/state';
import AiNode from '$lib/components/icons/AINode.svelte';
import Calculator from '$lib/components/icons/Calculator.svelte';
import FinChart from '$lib/components/icons/FinChart.svelte';
import GitHub from '$lib/components/icons/GitHub.svelte';
import Google from '$lib/components/icons/Google.svelte';
import Logo from '$lib/components/logos/Logo.svelte';
import ThemeSwitcher from '$lib/components/reusables/ThemeSwitcher.svelte';
import { BASE_API_URI } from '$lib/utils/contants';
import { fade } from 'svelte/transition';
const next = page.url.searchParams.get('next') || '/';
</script>
<div
class="relative min-h-screen bg-linear-to-br from-gray-100 to-gray-200 transition-colors duration-300 dark:from-gray-900 dark:to-gray-800"
>
<!-- Theme Toggle -->
<ThemeSwitcher
class="dark:ring-black-500/50 absolute top-4 right-4 z-50 cursor-pointer rounded-full bg-white p-2 shadow-lg transition-all duration-300 hover:shadow-xl dark:bg-gray-700 dark:ring-2"
/>
<!-- Decorative background elements -->
<div class="absolute inset-0 z-0 overflow-hidden">
<!-- AI Network Nodes -->
<div class="floating-icons absolute top-10 left-10 opacity-20 dark:opacity-30">
<AiNode />
</div>
<!-- Financial Chart -->
<div class="floating-icons absolute right-20 bottom-32 opacity-20 dark:opacity-30">
<FinChart />
</div>
<!-- Calculator Icon -->
<div class="floating-icons absolute top-20 right-10 opacity-20 dark:opacity-30">
<Calculator />
</div>
</div>
<!-- Main content -->
<div class="relative z-10 flex min-h-screen items-center justify-center">
<div
in:fade={{ duration: 300 }}
class="w-full max-w-md space-y-8 rounded-xl bg-white/80 p-8 shadow-lg backdrop-blur-xs transition-all duration-300 dark:bg-gray-800/90 dark:shadow-gray-900/30"
>
<!-- Logo -->
<div class="logo-container flex justify-center">
<Logo isSmall={false} class="h-12 w-auto" />
</div>
<!-- Header -->
<div class="text-center">
<h2 class="mt-6 text-3xl font-extrabold text-gray-900 dark:text-white">Welcome back</h2>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
Sign in to continue to your account
</p>
{#if page.url.searchParams.get('error')}
<p class="mt-2 text-sm text-red-500 dark:text-red-400">
Log in failed. Please try again.
</p>
{/if}
</div>
<!-- Social Login Buttons -->
<div class="mt-8 space-y-4">
<!-- GitHub Login Button -->
<a
href={`${BASE_API_URI}/v1/auth/github?next=${next}`}
class="flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-xs transition-all duration-300 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<GitHub />
Continue with GitHub
</a>
<!-- Google Login Button -->
<a
href="/auth/google"
class="pointer-events-none flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 opacity-50 shadow-xs transition-all duration-300 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
aria-disabled="true"
>
<Google />
Continue with Google
</a>
</div>
<!-- Divider -->
<div class="mt-6">
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300 dark:border-gray-600"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="bg-white px-2 text-gray-500 dark:bg-gray-800 dark:text-gray-400">
Policy Agreement
</span>
</div>
</div>
</div>
<!-- Additional Info -->
<div class="mt-6 text-center text-sm">
<p class="text-gray-600 dark:text-gray-400">
By continuing, you agree to our
<a
href="/terms"
class="font-medium text-indigo-600 transition-colors duration-300 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Terms of Service
</a>
and
<a
href="/privacy"
class="font-medium text-indigo-600 transition-colors duration-300 hover:text-indigo-500 dark:text-indigo-400 dark:hover:text-indigo-300"
>
Privacy Policy
</a>
</p>
</div>
</div>
</div>
</div>
<style>
.floating-icons {
animation: float 6s ease-in-out infinite;
}
.floating-icons:nth-child(2) {
animation-delay: 2s;
}
.floating-icons:nth-child(3) {
animation-delay: 4s;
}
@keyframes float {
0% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(360deg);
}
100% {
transform: translateY(0px) rotate(0deg);
}
}
</style>
Though this code seems long, the only really important part is:
<script lang="ts">
import { page } from '$app/state';
...
import { BASE_API_URI } from '$lib/utils/contants';
...
const next = page.url.searchParams.get('next') || '/';
</script>
...
<!-- Social Login Buttons -->
<div class="mt-8 space-y-4">
<!-- GitHub Login Button -->
<a
href={`${BASE_API_URI}/v1/auth/github?next=${next}`}
class="flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-xs transition-all duration-300 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<GitHub />
Continue with GitHub
</a>
<!-- Google Login Button -->
<a
href="/auth/google"
class="pointer-events-none flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 opacity-50 shadow-xs transition-all duration-300 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
aria-disabled="true"
>
<Google />
Continue with Google
</a>
</div>
...
These are just <a>
elements whose URLs link directly to the API endpoints (Google yet to be implemented). The BASE_API_URI
is exported by src/lib/utils/constants.ts
:
export const BASE_API_URI = import.meta.env.DEV
? import.meta.env.VITE_BASE_API_URI_DEV
: import.meta.env.VITE_BASE_API_URI_PROD;
It necessitates that you set either VITE_BASE_API_URI_DEV
or VITE_BASE_API_URI_PROD
as environment variables pointing to your server's base URL. In development, I set VITE_BASE_API_URI_DEV=http://localhost:3030/api
in a .env
file at the root of my SvelteKit project.
When deploying to a production server, configure the environment variable VITE_BASE_API_URI_PROD
to point to your backend's production URL, including the /api
path (e.g., https://your-production-url.com/api
). Most deployment platforms offer a way to set these variables.
Back in our +page.svelte
, we also retrieve the next
page, which specifies where a user should be redirected after successful authentication. This is configured on a per-route basis. In addition to these core features, the page includes visual elements such as infinitely animating floating icons. These icons—AiNode
(AI integration), FinChart
(finance), and Calculator
(analysis)—represent the application's key themes. Custom styles are applied to create a continuous vertical translation from 0px
to -20px
and back, combined with a simultaneous rotation from 0deg
to 360deg
. We could have used tailwind styles directly.
This article is getting long, so we will defer the user dashboard to the next article. However, there's a little safekeeping that needs to be done. Authenticated users should not be allowed to access this login page since they're already authenticated. To achieve this, we'll have a +page.server.ts
file whose aim is to redirect any authenticated user from coming here:
import { redirect } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
throw redirect(302, "/finanalyzer");
}
return {};
};
See you in the next one!
Outro
Enjoyed this article? I'm a Software Engineer, Technical Writer and Technical Support Engineer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and X. I am also an email away.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!