Skip to content

Repository files navigation

Messenger

A lightweight instant messaging application built with Express.js

Banner

About

This chat application allows users to create conversations and send messages to each other in real time.

Features:

  • One-to-one conversations
  • Group conversations (3 or more people)
  • Real-time updates when receiving messages or being added to conversations
  • Optimistic UI updates to minimize waiting for server operations
  • Website rendered as a SPA allowing navigation to different pages without a full browser refresh
  • Fully responsive UI (works on both mobile and desktop)
  • Account system

Technologies

Logos of used technologies

Frontend

  • React: A framework for manipulating the DOM declaratively.
  • v0 by Vercel: An AI tool used to build user interfaces from prompts. It outputs Next.js code by default. I used it to build the UI very quickly.
  • shadcn/ui: A registry of unstyled reusable components. Used for more complex components such as toast notifications. Also used by v0.
  • TailwindCSS: A CSS library that includes utility classes to allow styling of components direclty in the HTML.
  • Webpack: A build tool used to bundle all the React code into a single minified, optimized and compressed plain Javascript file ready for production.

Backend

  • Node.js: A runtime for Javascript on the server (note that ts-node is used to run the Typescript code which is not natively supported by Node).
  • Express.js: A framework for handling server-side routing, browser requests, middleware and the responses sent back to the browser.
  • Socket.io: A library for handling Websocket connections for realtime updates to the messages and conversations.
  • JSON Web Tokens: A stateless way of managing user authentication with symmetric encryption.
  • node-postgres: A library for sending SQL queries to the Postgres database from the Node server.

Deployment

  • Neon: A Postgres serverless runtime built with Rust. I highly recommend their Postgres tutorial.
  • Google Cloud Run: A cloud service that manages and runs the server instance.
  • Docker: A platform used to define a reproducible configuration for the server build, so it can be hosted anywhere.

What I Learned

Authentication with JWT

For this project I rolled out my own authentication to avoid importing a heavy library and to learn more about how to keep sensitive information secure.

When the user logs in, the server will generate a new token using the JWT_SECRET environment variable, which acts as the encryption / decryption key.

The token stores the user id in its payload, so it can be used to fetch the user that made the request if needed.

// auth.ts
export const getCurrentUser = async (req: Request, res: Response) => {
	const token = req.cookies.messenger_jwt
	try {
		const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { user_id: number };
		const user = await getUserById(decoded.user_id)
		res.status(200).json(user)
	} catch (err) {
		res.status(401).json({ error: (err as Error).message })
	}
}

Compression

I fell into a bit of a compression rabbit hole while trying to get that 100 Performance Lighthouse score.

I learned about the 4 main types of compression used by browsers (gzip, deflate, brotli and zstandard) and decided to also serve a Brotli-encoded variant of my bundled JS.

// webpack.config.ts
new CompressionPlugin({
	filename: '[path][base].br',
	algorithm: 'brotliCompress',
	test: /\.(js|css|html|svg)$/,
	compressionOptions: {
		level: 11
	},
	threshold: 10240,
	minRatio: 0.8,
	deleteOriginalAssets: false,
}),

The compression middleware in the compression.ts file will look into the Accept-Encoding header from the request to see if the browser supports Brotli.

This simple change reduces the size of the JS file sent over the network from 510 KB to 120 KB.

Real-time Communication

On the server, I store the Socket objects for each connected user in a hashmap, so I can only target the necessary users that should be notified.

// websockets.ts
const sockets: Map<number, Socket> = new Map()

export const emitMessage = async (message: Message) => {
	const targets = await getUsersInConversation(message.conversation_id, message.author_id) as { user_id: number }[]
	targets.forEach(u => {
		const socket = sockets.get(u.user_id)
		if (socket) {
			socket.emit('message', message)
			console.log(`Message from user ${message.author_id} received by ${u.user_id}`)
		}
	})
}

Note: It might be possible to use channels and subscriptions to achieve a cleaner implementation.

Deployment

Since this application requires long-lived connections between the server and client for realtime updates, serverless providers such as Vercel weren't an option.

I initially tried to upload the Github repo directly to Google Cloud Run but I didn't succeed.

To simplify the configuration, I got my server up and running using Docker before uploading it to Cloud Run again.

Further development

  • Light theme
  • Image sharing
  • Message reactions
  • Enhanced onboarding and usage instructions
  • Message encryption

About

A lightweight instant messaging application built with Express.js.

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages