Skip to content

vKxni/erlex

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

⚡ Erlex – High-Performance Chat Server

A small, embeddable chat server built with Elixir + Phoenix and Erlang, running on the BEAM VM.

Supports hundreds of thousands of concurrent connections thanks to BEAM's lightweight process model, lock-free ETS-based rate limiting, and Phoenix's PubSub fanout.


Tech Stack

Layer Technology
Language Elixir 1.19, Erlang/OTP 26
HTTP / WebSocket Phoenix 1.8 + Bandit
Real-time Phoenix Channels + Phoenix.Presence
Database PostgreSQL via Ecto
Auth Guardian (JWT) + Pbkdf2 password hashing
Rate limiting Custom Erlang ETS module (erlex_rate_limiter)
Message stats Custom Erlang ETS counter module (erlex_msg_stats)
Frontend Next.js 16 in web/

Quickstart (localhost)

# 1. Install dependencies
mix deps.get

# 2. Create & migrate database
mix ecto.setup

# 3. Start the backend
mix phx.server

# 4. In another terminal, start the web frontend
cd web
npm install
npm run dev

Open http://localhost:3000 for the user-facing frontend.

PostgreSQL: Make sure Postgres is running. Default credentials: postgres / postgres on localhost:5432.
Change them in config/dev.exs if needed.


Architecture Overview

Browser / External App
   │
   ├── Next.js frontend      →  /web          (separate app)
   │      │
   │      ├── REST proxy     →  /api/chat/*   (Next route handler)
   │      └── Phoenix WS     →  /chat/socket  (JWT-authenticated)
   │
   ├── REST API              →  /api/v1/*     (Phoenix JSON)
   │
   └── WebSocket Channel     →  /chat/socket  (Phoenix Channels)
              │
              ▼
     Phoenix PubSub  ←────────────── REST API POST / WS Channel
        │
        └──────────── Room:{id} / Dm:{id}

Every frontend connected to the same Phoenix backend shares the same rooms, DMs, and realtime message stream regardless of which website it is embedded into.


REST API Reference

Base URL: http://localhost:4000/api/v1
Protected endpoints require: Authorization: Bearer <token>

Authentication

Method Path Body Description
POST /auth/register {username, password} Create account → returns JWT
POST /auth/login {username, password} Login → returns JWT
DELETE /auth/logout Logout (client-side token discard)
GET /auth/me Current user info

Users

Method Path Description
GET /users List all users
GET /users/:id Get user by ID

Rooms (group chats)

Method Path Body Description
GET /rooms List all group rooms
POST /rooms {name, description?} Create room (auto-joins as admin)
GET /rooms/:id Room info + member list
DELETE /rooms/:id Delete room (creator only)
POST /rooms/:id/join Join a room
DELETE /rooms/:id/leave Leave a room
GET /rooms/:id/messages ?limit=50&before_id=... Message history (paginated)
POST /rooms/:id/messages {content} Send a message

Direct Messages

Method Path Body Description
GET /dm List your DM conversations
POST /dm {user_id} Start / get DM with a user
GET /dm/:id/messages ?limit=50 DM history
POST /dm/:id/messages {content} Send a DM

Response format

// Success
{ "data": { ... } }

// Error
{ "error": "message" }

// Validation error
{ "errors": { "field": ["message"] } }

WebSocket Channel API

Connect to: ws://localhost:4000/chat/socket/websocket?token=<JWT>

Joining a channel

// Group room
socket.channel("room:<room_uuid>")

// Direct message conversation
socket.channel("dm:<room_uuid>")

Client → Server events

Event Payload Description
new_message {content: "text"} Send a message
typing {} Start typing indicator
stop_typing {} Stop typing indicator

Server → Client events

Event Payload Description
message_history {messages: [...]} Last 50 messages on join
new_message {id, content, user, inserted_at, type} New message
typing {user_id, username} User is typing
stop_typing {user_id, username} User stopped typing
presence_state {user_id: {metas: [...]}, ...} Current online users
presence_diff {joins, leaves} Online users changed

Example: Connect with Phoenix.js

import { Socket } from "phoenix";

const socket = new Socket("ws://localhost:4000/chat/socket", {
  params: { token: myJwtToken }
});
socket.connect();

const channel = socket.channel("room:my-room-id");

channel.on("new_message", (msg) => {
  console.log(`${msg.user.username}: ${msg.content}`);
});

channel.on("message_history", ({ messages }) => {
  messages.forEach(renderMessage);
});

channel.join()
  .receive("ok", () => console.log("joined"))
  .receive("error", (err) => console.error("join failed", err));

// Send a message
channel.push("new_message", { content: "Hello!" });

Integration Example – TypeScript frontend

// lib/erlex-chat.ts
import { Socket, Channel } from "phoenix";

export class ErlexChat {
  private socket: Socket;
  private channels = new Map<string, Channel>();

  constructor(private apiBase: string, private wsBase: string) {}

  async login(username: string, password: string): Promise<string> {
    const res = await fetch(`${this.apiBase}/api/v1/auth/login`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ username, password }),
    });
    const { data } = await res.json();
    return data.token; // JWT
  }

  async connect(token: string) {
    this.socket = new Socket(`${this.wsBase}/chat/socket`, {
      params: { token },
    });
    this.socket.connect();
  }

  joinRoom(
    roomId: string,
    onMessage: (msg: any) => void
  ): Channel {
    const ch = this.socket.channel(`room:${roomId}`);

    ch.on("new_message", onMessage);
    ch.on("message_history", ({ messages }) => messages.forEach(onMessage));

    ch.join();
    this.channels.set(roomId, ch);
    return ch;
  }

  sendMessage(roomId: string, content: string) {
    this.channels.get(roomId)?.push("new_message", { content });
  }

  async getRooms(token: string) {
    const res = await fetch(`${this.apiBase}/api/v1/rooms`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    return res.json();
  }

  disconnect() {
    this.socket?.disconnect();
  }
}

// Usage in a Next.js component
import { ErlexChat } from "@/lib/erlex-chat";

const chat = new ErlexChat("http://localhost:4000", "ws://localhost:4000");

const token = await chat.login("alice", "secret123");
await chat.connect(token);
chat.joinRoom("room-uuid-here", (msg) => {
  console.log(msg.user.username, ":", msg.content);
});

Rate Limiting (Erlang Module)

src/erlex_rate_limiter.erl implements a fixed-window atomic limiter using Erlang ETS tables
(lock-free concurrent reads, atomic counter updates):

Context Limit Window
Shared message send (API + WS) 10 msgs 5 s

Call from Elixir:

case :erlex_rate_limiter.check_rate("user:#{user_id}", 20, 5_000) do
  {:allow, remaining} -> # proceed
  {:deny, retry_after_ms} -> # reject
end

Message Statistics (Erlang Module)

src/erlex_msg_stats.erl tracks per-room message counters using atomic ETS updates:

:erlex_msg_stats.record_message(room_id)     # -> total count
:erlex_msg_stats.get_room_stats(room_id)     # -> integer count
:erlex_msg_stats.get_all_stats()             # -> [{{:room, id}, count}]

Configuration

Database (config/dev.exs)

config :erlex, Erlex.Repo,
  username: "postgres",
  password: "postgres",
  hostname: "localhost",
  database: "erlex_dev"

JWT Secret (config/config.exs)

config :erlex, Erlex.Auth.Guardian,
  secret_key: "CHANGE_THIS_IN_PRODUCTION"  # min 32 chars

In production, set GUARDIAN_SECRET_KEY env variable and read it in config/runtime.exs.


Project Structure

erlex/
├── src/
│   ├── erlex_rate_limiter.erl   # Erlang: token-bucket rate limiter (ETS)
│   └── erlex_msg_stats.erl      # Erlang: per-room message counters (ETS)
│
├── lib/
│   ├── erlex/
│   │   ├── accounts/
│   │   │   ├── user.ex          # User schema + Pbkdf2 password hashing
│   │   │   └── accounts.ex      # Accounts context
│   │   ├── auth/
│   │   │   ├── guardian.ex      # JWT encode/decode
│   │   │   └── error_handler.ex # 401 JSON errors
│   │   ├── chat/
│   │   │   ├── room.ex          # Room schema
│   │   │   ├── room_member.ex   # Membership schema
│   │   │   ├── message.ex       # Message schema
│   │   │   └── chat.ex          # Chat context (rooms, DMs, messages)
│   │   ├── application.ex       # OTP Application (starts ETS tables)
│   │   └── repo.ex
│   │
│   └── erlex_web/
│       ├── channels/
│       │   ├── user_socket.ex   # JWT-authenticated WebSocket socket
│       │   └── room_channel.ex  # Room + DM channel logic
│       ├── controllers/
│       │   ├── api/             # REST API controllers
│       │   ├── error_html.ex
│       │   └── error_json.ex
│       ├── plugs/
│       │   └── api_auth.ex      # Bearer JWT auth plug
│       ├── presence.ex          # Phoenix.Presence (online tracking)
│       ├── endpoint.ex          # Phoenix backend endpoint
│       └── router.ex            # API + dev dashboard routes only
│
├── priv/repo/migrations/        # Ecto migrations
├── sample-site/                 # Minimal third-party website integration simulation
├── web/                         # Next.js frontend client
└── config/

Third-Party Website Simulation

To simulate a random external website adding your chat service, open the demo in:

  • sample-site/index.html

Use different tenant slugs per website (for example mika-travel-blog and other-website). Rooms/messages stay isolated per tenant.


CORS

All /api/v1/* endpoints allow * origins by default (cors_plug).
For production, set origin: ["https://yourdomain.com"] in the :api pipeline.

About

Embeddable chat API service for any web tool

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors