Skip to content

Latest commit

 

History

16 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

arifa-server

Arifa Server is a lightweight realtime WebSocket gateway built with actix-web and actix-ws.

It uses NATS through arifa-nats for realtime pub/sub fan out and Redis for WebSocket session state.

The gateway provides:

  • WebSocket connections
  • JWT authentication
  • Per user realtime channels
  • NATS based message routing
  • Cross service notification delivery
  • Connection/disconnection lifecycle events
  • Session heartbeat tracking
  • Automatic stale session eviction
  • Multiple gateway node support

A typical architecture looks like this:

flowchart TB
    A[Other Services<br/>API / Backend / Jobs] -- "publish (arifa.route)" --> B[arifa-server]
    C[WebSocket Client] <--> B
    B -- "publish (arifa.internal)" --> D[Other Services<br/>Lifecycle Events]
    B <--> E[(Redis<br/>session state)]
    B <--> F[(NATS)]
Loading

Using arifa-server, a quick orientation

There are two kinds of consumers of this gateway, and they talk to it differently:

flowchart LR
    subgraph Frontend["Frontend / mobile client"]
        direction TB
        C1[Open WebSocket] --> C2[Send auth frame] --> C3[Receive WsMessages]
    end
    subgraph Backend["Backend service"]
        direction TB
        S1[Build a WsMessage] --> S2[Wrap in NotifyEnvelope] --> S3["Publish to arifa.route (NATS)"]
    end
    Frontend -. "wss://.../ws/connect" .-> Gateway[arifa-server]
    Backend -. "NATS" .-> Gateway
Loading

Architecture

WebSocket endpoint

The main WebSocket endpoint is:

/ws/connect

A client connects using:

ws://<host>:<port>/ws/connect

or, in production:

wss://<host>:<port>/ws/connect

The connection lifecycle:

flowchart TD
    A[Connect] --> B[Wait for auth<br/>first message, 10s timeout]
    B -->|invalid / timeout / bad JSON| Z[Close socket]
    B -->|valid JWT| C[Subscribe to User::user_id]
    C --> D[Authenticated]
    D --> E[Receive realtime messages]
    E --> F[Disconnect]
    F --> G[Unsubscribe]
    F --> H[Remove heartbeat entry]
    F --> I[Emit disconnected event]
Loading

Authentication

The first WebSocket message must be an authentication message.

The server expects:

{
  "type": "auth",
  "token": "<your-jwt>"
}

Authentication must happen within 10 seconds, checked every second.

The connection is closed if:

  • the first message isn't received within 10 seconds
  • the message contains invalid JSON
  • type isn't auth
  • the JWT is invalid
  • the JWT is expired
  • the JWT doesn't satisfy the configured authentication requirements

After successful authentication, the server sends:

{
  "type": "authenticated",
  "user_id": "123"
}

The user is then subscribed to:

User::<user_id>

For example: User::123.


Issuing JWTs (for other services)

arifa-server doesn't issue tokens, it only verifies them. Any service that authenticates WebSocket clients, or that needs to hand a client a token to connect with, has to mint a JWT that satisfies the validator below.

This is the actual verifier used by the gateway:

use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, errors::Error as JwtError};
use serde::{Deserialize, Serialize};
use std::env;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Claims {
    pub sub: String,
    pub exp: usize,
    #[serde(default)]
    pub iss: Option<String>,
    #[serde(default)]
    pub aud: Option<String>,
}

#[derive(Clone)]
pub struct JwtAuth {
    decoding_key: DecodingKey,
    validation: Validation,
}

impl JwtAuth {
    pub fn from_env() -> Result<Self, JwtError> {
        let secret = env::var("ARIFA_JWT_SECRET").expect("ARIFA_JWT_SECRET must be set");
        let algorithm = env::var("ARIFA_JWT_ALGORITHM").unwrap_or_else(|_| "HS256".to_string());
        let algorithm = match algorithm.as_str() {
            "HS256" => Algorithm::HS256,
            "HS384" => Algorithm::HS384,
            "HS512" => Algorithm::HS512,
            _ => panic!("Unsupported ARIFA_JWT_ALGORITHM"),
        };
        let mut validation = Validation::new(algorithm);
        if let Ok(issuer) = env::var("ARIFA_JWT_ISSUER") {
            validation.set_issuer(&[issuer]);
        }
        if let Ok(audience) = env::var("ARIFA_JWT_AUDIENCE") {
            validation.set_audience(&[audience]);
        }
        Ok(Self {
            decoding_key: DecodingKey::from_secret(secret.as_bytes()),
            validation,
        })
    }

    pub async fn is_token_valid(&self, token: &str) -> Result<Claims, JwtError> {
        let token_data = decode::<Claims>(token, &self.decoding_key, &self.validation)?;
        Ok(token_data.claims)
    }
}

What the issuing service must match

Requirement Comes from Notes
Signing secret ARIFA_JWT_SECRET Symmetric (HMAC). The issuer needs the raw secret value, byte for byte. No public/private key split.
Algorithm ARIFA_JWT_ALGORITHM One of HS256, HS384, HS512. Must match exactly, or decode fails. Defaults to HS256 if unset.
sub claim Claims.sub Required, must be a string (the user id). Missing or wrong type means deserialization fails even with a valid signature.
exp claim Claims.exp Required. jsonwebtoken checks this automatically against the current time. Omit it and the token gets rejected.
iss claim ARIFA_JWT_ISSUER (optional) Only enforced if this env var is set on the gateway. If set, the token's iss must match exactly.
aud claim ARIFA_JWT_AUDIENCE (optional) Only enforced if this env var is set on the gateway. If set, the token's aud must match exactly.

Minimum claims payload (matching the .env example in Configuration):

{
  "sub": "123",
  "exp": 1755331200,
  "iss": "mtaa",
  "aud": "mtaa-api"
}

iss/aud are only strictly required if the gateway has ARIFA_JWT_ISSUER / ARIFA_JWT_AUDIENCE set, but it's good practice to include them even when optional, since the gateway's config can change independently of the issuer.

Example: issuing a token (Rust)

use jsonwebtoken::{encode, EncodingKey, Header, Algorithm};
use serde::Serialize;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Serialize)]
struct Claims {
    sub: String,
    exp: usize,
    iss: String,
    aud: String,
}

fn issue_token(user_id: &str, secret: &str) -> String {
    let exp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs() as usize
        + 3600; // 1 hour

    let claims = Claims {
        sub: user_id.to_string(),
        exp,
        iss: "mtaa".to_string(),
        aud: "mtaa-api".to_string(),
    };

    let header = Header::new(Algorithm::HS256); // match ARIFA_JWT_ALGORITHM
    encode(&header, &claims, &EncodingKey::from_secret(secret.as_bytes())).unwrap()
}

Example: issuing a token (Node.js)

import jwt from "jsonwebtoken";

const token = jwt.sign(
  { sub: "123" }, // user_id, must serialize as a string
  process.env.ARIFA_JWT_SECRET,
  {
    algorithm: "HS256",                       // match ARIFA_JWT_ALGORITHM
    expiresIn: "1h",                          // sets exp automatically
    issuer: process.env.ARIFA_JWT_ISSUER,     // e.g. "mtaa"
    audience: process.env.ARIFA_JWT_AUDIENCE, // e.g. "mtaa-api"
  }
);

Example: issuing a token (Python)

import jwt
import time

token = jwt.encode(
    {
        "sub": "123",
        "exp": int(time.time()) + 3600,
        "iss": "mtaa",
        "aud": "mtaa-api",
    },
    ARIFA_JWT_SECRET,
    algorithm="HS256",
)

Gotchas

  • Shared secret across services. Because this uses HMAC (symmetric signing), every service that issues tokens needs the raw ARIFA_JWT_SECRET. Treat it as shared infrastructure and rotate it everywhere at once if it's ever exposed. If you need issuers to not hold the raw secret, switch to an asymmetric algorithm (RS256/ES256) where issuers hold a private key and the gateway only holds the public key.
  • No clock skew leeway by default. jsonwebtoken's validator has zero leeway on exp unless explicitly configured. If the issuing service's clock drifts from the gateway's, tokens can get rejected right at the edge of expiry.
  • sub must be a string. If your user IDs are numeric internally, serialize them as strings in the JWT ("sub": "123", not "sub": 123). Claims.sub is typed as String and won't coerce a JSON number.

Message model

Messages delivered to WebSocket clients use the following structure:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageScope {
    Broadcast,
    Private,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MessageKind {
    Feeds,
    DirectMessage,
    Event,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WsMessage {
    pub scope: MessageScope,
    pub kind: MessageKind,
    pub node_id: Option<String>,
    pub payload: serde_json::Value,
}

Scope

scope determines who the message is intended for: broadcast or private.

Message kind

kind identifies the type of realtime event: feeds, direct_message, or event.

node_id

node_id identifies the originating server/node. It's optional, null or a string like "node-1". The gateway doesn't require it to be present for delivery.

payload

payload is a free form serde_json::Value. Its shape is entirely up to the sending service; the gateway doesn't interpret it.


WsMessage JSON format

{
  "scope": "broadcast",
  "kind": "feeds",
  "node_id": "node-1",
  "payload": {}
}
Field Type Required Description
scope string Yes broadcast or private
kind string Yes feeds, direct_message, or event
node_id string/null No Originating node identifier
payload object/value Yes Application specific data

Message examples

1. Feed message

{
  "scope": "broadcast",
  "kind": "feeds",
  "node_id": "node-1",
  "payload": {
    "product_id": "123",
    "action": "created",
    "title": "New product",
    "price": 1500
  }
}

Useful for realtime feed updates: new/updated/removed listings, nearby items, feed refresh events. payload shape is controlled by the sending application.

2. Direct message

{
  "scope": "private",
  "kind": "direct_message",
  "node_id": "node-1",
  "payload": {
    "message_id": "msg-123",
    "sender_id": "42",
    "recipient_id": "123",
    "message": "Hello",
    "created_at": "2026-08-14T15:30:00Z"
  }
}

The gateway doesn't interpret payload, it just transports it.

3. Event message

{
  "scope": "private",
  "kind": "event",
  "node_id": "node-1",
  "payload": {
    "event": "order_updated",
    "order_id": "order-123",
    "status": "completed"
  }
}

Useful for order updates, payment notifications, shop notifications, and other application specific events.


Routing messages to WebSocket clients

Other services communicate with arifa-server through NATS, publishing to arifa.route.

flowchart LR
    A[Other service] -- "publish NotifyEnvelope" --> B["arifa.route (NATS subject)"]
    B --> C[arifa-server inbound task]
    C -- "arifa.publish(channel, message)" --> D["User::user_id"]
    D --> E[WebSocket client]
Loading

The routing envelope (NotifyEnvelope):

{
  "channel": "User::123",
  "message": {
    "scope": "private",
    "kind": "direct_message",
    "node_id": "node-1",
    "payload": {
      "message_id": "msg-123",
      "sender_id": "42",
      "recipient_id": "123",
      "message": "Hello"
    }
  }
}

channel determines which WebSocket subscribers receive the message. message is a full WsMessage as described above.

User channels

Authenticated users are automatically subscribed to User::<user_id>, for user 123 that's User::123. To message that user, publish:

{
  "channel": "User::123",
  "message": {
    "scope": "private",
    "kind": "direct_message",
    "node_id": "api-1",
    "payload": {
      "message_id": "msg-123",
      "sender_id": "42",
      "recipient_id": "123",
      "message": "You have a new message"
    }
  }
}

The gateway receives this on arifa.route and republishes message onto User::123. Any active WebSocket session subscribed to that channel receives it.

Broadcast messages

{
  "channel": "User::123",
  "message": {
    "scope": "broadcast",
    "kind": "feeds",
    "node_id": "api-1",
    "payload": {
      "action": "new_product",
      "product_id": "product-123"
    }
  }
}

The meaning of broadcast vs private is defined by your application protocol. The gateway's actual routing mechanism is the NATS channel supplied in the NotifyEnvelope.


NATS architecture

arifa-server uses two main NATS subjects.

arifa.route, inbound to the gateway

sequenceDiagram
    participant S as Other service
    participant N as NATS (arifa.route)
    participant G as arifa-server
    participant U as User::<user_id>
    participant C as WebSocket client

    S->>N: publish NotifyEnvelope
    N->>G: deliver envelope
    G->>U: arifa.publish(channel, message)
    U->>C: deliver WsMessage
Loading

arifa.internal, outbound lifecycle events

Used for gateway lifecycle events. The gateway publishes an event when sessions connect and disconnect:

{ "event": "user.connected", "session_id": "session-123", "user_id": "123", "online_users": 42 }
{ "event": "user.disconnected", "session_id": "session-123", "user_id": "123", "online_users": 41 }

Other services subscribe to arifa.internal to monitor connection activity.


NATS bridge

The NATS bridge is two background tasks sharing one plain async_nats::Client.

Outbound task

flowchart LR
    A[WebSocket Connected/Disconnected] --> B[InternalEvent]
    B --> C[mpsc queue]
    C --> D[Outbound bridge task]
    D -->|stamps online_users count| E["arifa.internal"]
Loading
  1. receives internal InternalEvents from an mpsc queue
  2. calculates the current online_users() count
  3. adds the count to the event
  4. serializes and publishes it to arifa.internal

Inbound task

Subscribes to arifa.route, receives a NotifyEnvelope, and republishes the WsMessage payload onto the target channel (e.g. User::123). This lets services completely separate from the gateway send realtime notifications.


Redis

Redis is used for WebSocket session state.

REDIS_URL=redis://127.0.0.1:6379

For production, use your Redis provider's connection URL, e.g.:

REDIS_URL=redis://username:password@host:6379

Heartbeat system

flowchart TD
    A[WebSocket connected] --> B[Heartbeat registered]
    B --> C[Ping/Pong activity]
    C --> D[Heartbeat timestamp updated]
    D --> E["Sweeper checks sessions (every 15s)"]
    E -->|active| C
    E -->|no pong for 60s| F[Evict session]
Loading

The sweeper runs every 15 seconds; a session is considered stale after 60 seconds without a pong. The server also handles Ping, Pong, and Close frames directly. When a session disconnects, its heartbeat entry gets removed.

Clients should respond to server Pings with Pong (or send their own Pings) to stay connected.


Prerequisites

  • Rust toolchain (stable), only needed if building from source
  • A running NATS server
  • A running Redis server
rustc --version
cargo --version
nats-server --version
redis-server --version

Installing a prebuilt release

If you don't want to build from source, grab a prebuilt binary from the Releases page. The example below installs v0.1.4, swap the version and asset name for your platform.

1. Pick the asset for your platform:

Platform Asset
Linux x86_64 arifa-server-v0.1.4-linux-x86_64.tar.gz
Linux aarch64 arifa-server-v0.1.4-linux-aarch64.tar.gz
macOS x86_64 arifa-server-v0.1.4-macos-x86_64.tar.gz
macOS aarch64 arifa-server-v0.1.4-macos-aarch64.tar.gz

2. Download with wget and verify the checksum:

VERSION=v0.1.4
ASSET=arifa-server-${VERSION}-linux-x86_64.tar.gz   # change per platform

wget "https://github.com/anomalous254/arifa-server/releases/download/${VERSION}/${ASSET}"
wget "https://github.com/anomalous254/arifa-server/releases/download/${VERSION}/${ASSET}.sha256"
sha256sum -c "${ASSET}.sha256"

3. Extract and install:

tar -xzf "${ASSET}"
chmod +x arifa-server
sudo mv arifa-server /usr/local/bin/arifa-server

4. Confirm it works and see usage:

Since /usr/local/bin is typically on your PATH, you can just run:

arifa-server --version
arifa-server --help

If you'd rather run it directly from the extracted folder without installing it system wide, prefix it with ./:

./arifa-server --version
./arifa-server --help
./arifa-server --info

5. Start the server:

Once NATS_URL, REDIS_URL, and your JWT env vars are set (see Configuration), start it with no flags:

arifa-server
# or, if running from the extracted folder:
./arifa-server

Building from source instead

git clone https://github.com/anomalous254/arifa-server.git
cd arifa-server
cargo build --release --bin arifa-server
./target/release/arifa-server

The build output isn't on your PATH by default, so run it with ./target/release/arifa-server (or ./arifa-server if you've copied/renamed the binary into your working directory), the same way as the prebuilt release usage above.


CLI usage

arifa-server [OPTIONS]
Option Description
-h, --help Show help and exit
-V, --version Show version and exit
-F, --info Show info about the server and developer
(none) Start the server normally

An unrecognized flag exits with status code 2 and a usage hint.

Examples

# show version
arifa-server --version
./arifa-server -V

# show help
arifa-server --help
./arifa-server -h

# show server + developer info
arifa-server --info
./arifa-server -F

# start the server (no flags)
arifa-server
./arifa-server

Configuration

Create a .env file in the project root:

NATS_URL=nats://127.0.0.1:4222
REDIS_URL=redis://127.0.0.1:6379

NODE_ID=node-1
ARIFA_NODE_ID=node-1
ARIFA_HOST=0.0.0.0
ARIFA_PORT=8080

ARIFA_JWT_SECRET=your-secret
ARIFA_JWT_ALGORITHM=HS256
ARIFA_JWT_ISSUER=mtaa
ARIFA_JWT_AUDIENCE=mtaa-api
ARIFA_AUTH_ENABLED=true

Environment variables

Variable Description Example
NATS_URL NATS server URL nats://127.0.0.1:4222
REDIS_URL Redis connection URL redis://127.0.0.1:6379
NODE_ID Gateway node identifier node-1
ARIFA_HOST HTTP/WebSocket bind address 0.0.0.0
ARIFA_PORT HTTP/WebSocket port 8080
ARIFA_JWT_SECRET JWT signing secret, also required by any service issuing tokens, see Issuing JWTs your-secret
ARIFA_JWT_ALGORITHM JWT algorithm, must match on the issuing side too HS256
ARIFA_JWT_ISSUER Expected JWT issuer (optional, enforced if set) mtaa
ARIFA_JWT_AUDIENCE Expected JWT audience (optional, enforced if set) mtaa-api
ARIFA_AUTH_ENABLED Enable JWT authentication true

Running locally

flowchart LR
    A[nats-server] --> C[arifa-server]
    B[redis-server] --> C
    C --> D["cargo run --bin arifa-server"]
Loading
# terminal 1
nats-server

# terminal 2
redis-server

# terminal 3
cargo run --bin arifa-server
# or, for a release build:
cargo build --release --bin arifa-server
./target/release/arifa-server

Expected startup output:

server starting host=0.0.0.0 port=8080

The server panics on boot (by design, via .expect(...)) if NATS_URL, REDIS_URL, the NATS connection, or JWT env vars are missing/invalid. The panic message names the exact missing variable.


WebSocket connection

Connect to:

ws://localhost:8080/ws/connect        # local dev
wss://your-domain.com/ws/connect      # production
  1. Open the WebSocket connection.

  2. Send the auth frame as your first message, within 10 seconds:

    { "type": "auth", "token": "<JWT>" }

    See Issuing JWTs if you need to know how <JWT> is generated.

  3. On success, you receive:

    { "type": "authenticated", "user_id": "123" }

    You're now subscribed to User::123 and receive any WsMessage routed there.

  4. Respond to server Pings with Pong (or send your own Pings) to keep the heartbeat alive.

If auth fails (bad JSON, wrong type, invalid/expired token, or timeout), the server closes the socket with a policy close code and a descriptive reason string.

Receiving a realtime message

If another service publishes this to arifa.route:

{
  "channel": "User::123",
  "message": {
    "scope": "private",
    "kind": "event",
    "node_id": "api-1",
    "payload": {
      "event": "notification",
      "title": "New notification",
      "message": "You have a new notification"
    }
  }
}

...the WebSocket client receives just the inner message object (the WsMessage). The NotifyEnvelope is internal NATS routing and is never sent to the client:

{
  "scope": "private",
  "kind": "event",
  "node_id": "api-1",
  "payload": {
    "event": "notification",
    "title": "New notification",
    "message": "You have a new notification"
  }
}

Example service integration

let message = WsMessage {
    scope: MessageScope::Private,
    kind: MessageKind::DirectMessage,
    node_id: Some("api-1".to_string()),
    payload: serde_json::json!({
        "message_id": "msg-123",
        "sender_id": "42",
        "recipient_id": "123",
        "message": "Hello"
    }),
};

Wrap it in the routing envelope and publish the serialized JSON to arifa.route:

{
  "channel": "User::123",
  "message": {
    "scope": "private",
    "kind": "direct_message",
    "node_id": "api-1",
    "payload": {
      "message_id": "msg-123",
      "sender_id": "42",
      "recipient_id": "123",
      "message": "Hello"
    }
  }
}

Multi-node architecture

flowchart TB
    N[(NATS)]
    N --- G1[arifa-server-1<br/>node-1]
    N --- G2[arifa-server-2<br/>node-2]
    G1 --- C1[Clients]
    G2 --- C2[Clients]
Loading

Multiple arifa-server instances can run simultaneously. A service publishing to arifa.route doesn't need to know which gateway node currently owns the user's WebSocket connection. NATS provides the cross node messaging layer, and whichever node holds the relevant subscription delivers the message.


Error handling

Authentication failures close the WebSocket connection. Possible causes:

  • authentication timeout
  • malformed JSON
  • wrong message type
  • missing token
  • invalid JWT
  • expired JWT
  • invalid JWT claims

The server uses a WebSocket policy close code with a descriptive reason. Treat the exact reason string as diagnostic information, not a stable API contract.


Shutdown

AppState::shutdown() stops the background Arifa router/forwarding tasks. The bundled main() wires this up for you on Ctrl+C (SIGINT):

flowchart TD
    A[SIGINT / Ctrl+C] --> B[app_state.shutdown]
    B --> C[Arifa background tasks stop]
    C --> D[handle.stop true]
    D --> E[HTTP/WebSocket server exits]
Loading

If you run arifa-server under a process manager that sends SIGTERM instead (systemd, Docker, Kubernetes), make sure that signal reaches the same shutdown path. start_server() doesn't call AppState::shutdown() automatically on its own.


Production deployment

Use wss:// (not ws://) in production, and put a reverse proxy in front of the gateway for TLS termination:

flowchart TB
    I[Internet] --> P[Reverse proxy<br/>HTTPS/WSS + TLS termination]
    P --> G1[arifa-server-1]
    P --> G2[arifa-server-2]
    G1 --- N[(NATS)]
    G2 --- N
    G1 --- R[(Redis)]
    G2 --- R
Loading

1. Run the binary under a process supervisor

Example systemd unit (/etc/systemd/system/arifa-server.service):

[Unit]
Description=arifa-server
After=network.target

[Service]
Type=simple
User=arifa
WorkingDirectory=/opt/arifa-server
EnvironmentFile=/opt/arifa-server/.env
ExecStart=/usr/local/bin/arifa-server
Restart=on-failure
RestartSec=2
KillSignal=SIGTERM
TimeoutStopSec=10

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now arifa-server
sudo systemctl status arifa-server
journalctl -u arifa-server -f

KillSignal=SIGTERM makes sure systemctl stop sends the signal your process needs to catch and forward into app_state.shutdown().

2. Terminate TLS with a reverse proxy

Example nginx config for WebSocket + TLS:

server {
    listen 443 ssl http2;
    server_name api.example.com;

    ssl_certificate     /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    location /ws/connect {
        proxy_pass http://127.0.0.1:8080/ws/connect;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;

        # WebSocket connections are long lived, don't let the proxy
        # time out an idle but alive session
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

Obtain the certificate with, e.g., certbot --nginx -d api.example.com.

Clients then connect to wss://api.example.com/ws/connect instead of talking to port 8080 directly.

3. Run multiple nodes behind the proxy

Point the reverse proxy at more than one arifa-server instance (different ports or hosts) for horizontal scale and failover, see Multi-node architecture. All nodes share the same NATS cluster, so a message published to arifa.route reaches the user regardless of which node holds their connection.

4. Secrets and environment

  • Never commit .env or ARIFA_JWT_SECRET to git.
  • Use your platform's secret store (systemd EnvironmentFile with restricted permissions, Docker secrets, Kubernetes Secret, or your cloud provider's secrets manager) instead of plain files where possible.
  • Use a long, random ARIFA_JWT_SECRET and rotate it if it's ever exposed. Remember, every service issuing tokens (see Issuing JWTs) needs this same secret, so rotation has to be coordinated across all of them.

5. NATS and Redis

  • Keep NATS and Redis on a private network, don't expose them to the public internet.
  • Enable NATS auth (nats-server supports token/user/TLS auth) for anything beyond local development.
  • Use a managed or clustered Redis in production for durability if session state loss on restart matters to you.

6. Production checklist

  • wss:// only, TLS terminated at the proxy or the app
  • Strong, unique ARIFA_JWT_SECRET, not committed to source control
  • NATS and Redis restricted to a private network, with auth enabled
  • Process supervisor configured to forward SIGTERM (systemd/Docker/K8s)
  • Reverse proxy timeouts tuned for long lived WebSocket connections
  • Multiple nodes behind the proxy if you need horizontal scale or zero downtime deploys
  • Logs shipped somewhere durable (journalctl, or your log aggregator of choice)

Protocol summary

Client → arifa-server (auth):

{ "type": "auth", "token": "<JWT>" }

arifa-server → Client (auth success):

{ "type": "authenticated", "user_id": "123" }

arifa-server → Client (realtime message):

{ "scope": "private", "kind": "event", "node_id": "api-1", "payload": {} }

Service → arifa-server (NATS subject arifa.route):

{
  "channel": "User::123",
  "message": { "scope": "private", "kind": "event", "node_id": "api-1", "payload": {} }
}

arifa-server → Services (NATS subject arifa.internal):

{ "event": "user.connected", "session_id": "session-123", "user_id": "123", "online_users": 42 }
{ "event": "user.disconnected", "session_id": "session-123", "user_id": "123", "online_users": 41 }

Complete realtime flow

sequenceDiagram
    participant C as Client
    participant G as arifa-server
    participant S as Other service
    participant N as NATS (arifa.route)

    C->>G: Connect /ws/connect
    C->>G: {"type":"auth","token":"<JWT>"}
    G->>G: Validate JWT
    G->>C: {"type":"authenticated","user_id":"123"}
    G->>G: Subscribe User::123
    Note over S: Service creates notification
    S->>S: Build WsMessage
    S->>N: Publish NotifyEnvelope
    N->>G: Deliver envelope
    G->>C: Publish WsMessage to User::123
    C->>C: Process payload
Loading

The gateway acts as a transport and routing layer; application services remain responsible for constructing the actual business payload.


Developer

Name Peter Nyando
Email nyandopeter2@gmail.com
GitHub github.com/anomalous254
Repo github.com/anomalous254/arifa-server

License

See the repository license for licensing information.

Releases

Packages

Contributors

Languages