436
Documentation

Realtime (v1)

Realtime communication with Socket.IO. Built-in authentication, validation, rate limiting, and multi-instance support.

Creating Realtime Routes

Create realtime routes using the same file-based routing pattern as pages and APIs. Add an events.ts file in the app/wss directory:

app/wss/chat/events.ts
import { defineWssRoute } from "@lolyjs/core";
import { z } from "zod";

export default defineWssRoute({
  // Authentication hook
  auth: async (ctx) => {
    const token = ctx.req.headers.authorization?.replace("Bearer ", "");
    if (!token) return null;
    return await verifyToken(token);
  },
  
  // Connection handler
  onConnect: (ctx) => {
    console.log("User connected:", ctx.user?.id);
  },
  
  // Disconnection handler
  onDisconnect: (ctx, reason) => {
    console.log("User disconnected:", ctx.user?.id, reason);
  },
  
  // Event handlers
  // IMPORTANT: Event names MUST be lowercase only (no uppercase letters)
  // ✅ CORRECT: "message", "user_message", "chat_message"
  // ❌ WRONG: "Message", "userMessage", "UserMessage"
  events: {
    message: {
      // Schema validation (Zod or Valibot)
      schema: z.object({
        text: z.string().min(1).max(500),
      }),
      
      // Guard (permissions check)
      guard: ({ user }) => !!user, // Requires authentication
      
      // Rate limiting
      rateLimit: {
        eventsPerSecond: 10,
        burst: 20,
      },
      
      // Handler
      handler: (ctx) => {
        // Event names in emit/broadcast must also be lowercase
        ctx.actions.broadcast("message", {
          text: ctx.data.text,
          from: ctx.user?.id,
        });
      },
    },
  },
});

⚠️ Important: Event Names Must Be Lowercase

Event names in the events object and when using ctx.actions.emit() or ctx.actions.broadcast() must be in lowercase only. Event names cannot contain uppercase letters. Use "message", "user_message", not "Message" or "userMessage".

This creates a realtime namespace at /chat with built-in features.

Using defineWssRoute()

The defineWssRoute() API provides:

  • Authentication hooks
  • Schema validation with Zod/Valibot
  • Permission guards
  • Rate limiting (global and per-event)
  • State store for shared state (cluster-ready)
  • User targeting across instances
  • Rooms support

Authentication

The auth hook runs before onConnect:

app/wss/chat/events.ts
import { defineWssRoute } from "@lolyjs/core";

export default defineWssRoute({
  auth: async (ctx) => {
    // Verify JWT token
    const token = ctx.req.headers.authorization?.replace("Bearer ", "");
    if (!token) return null;
    
    const user = await verifyJWT(token);
    return user; // or null if not authenticated
  },
  
  onConnect: (ctx) => {
    // ctx.user is available here (from auth hook)
    console.log("Authenticated user:", ctx.user?.id);
  },
  
  events: {
    // ...
  },
});

Validation

Use Zod or Valibot schemas to validate event data:

app/wss/chat/events.ts
import { defineWssRoute } from "@lolyjs/core";
import { z } from "zod";

export default defineWssRoute({
  events: {
    message: {
      schema: z.object({
        text: z.string().min(1).max(500),
        roomId: z.string().uuid(),
      }),
      handler: (ctx) => {
        // ctx.data is validated and typed
        console.log(ctx.data.text); // TypeScript knows it's a string
      },
    },
  },
});

If validation fails, the framework automatically emits __loly:error with code BAD_PAYLOAD.

Guards & Permissions

Guards verify permissions before executing handlers:

app/wss/admin/events.ts
import { defineWssRoute } from "@lolyjs/core";

export default defineWssRoute({
  events: {
    "admin-action": {
      guard: ({ user }) => user?.role === "admin",
      handler: (ctx) => {
        // Only admins can execute this
      },
    },
    
    "user-action": {
      guard: ({ user }) => !!user, // Requires authentication
      handler: (ctx) => {
        // Authenticated users only
      },
    },
  },
});

If a guard returns false, the framework emits __loly:error with code FORBIDDEN.

Rate Limiting

Configure rate limits globally and per-event:

app/wss/chat/events.ts
import { defineWssRoute } from "@lolyjs/core";

export default defineWssRoute({
  events: {
    "spam-prone": {
      rateLimit: {
        eventsPerSecond: 5,  // Max 5 per second
        burst: 10,           // Allow bursts of up to 10
      },
      handler: (ctx) => {
        // ...
      },
    },
  },
});

If the limit is exceeded, the framework emits __loly:error with code RATE_LIMIT.

State Store

Access shared state across instances (cluster mode):

app/wss/counter/events.ts
import { defineWssRoute } from "@lolyjs/core";
import { z } from "zod";

export default defineWssRoute({
  onConnect: async (ctx) => {
    // Get initial counter value
    const count = await ctx.state.get<number>("counter:value") || 0;
    ctx.actions.reply("counter-state", { count });
  },
  
  events: {
    increment: {
      schema: z.object({ by: z.number().int().min(1).max(10).default(1) }),
      handler: async (ctx) => {
        const by = ctx.data.by || 1;
        const newCount = await ctx.state.incr("counter:value", by);
        
        // Broadcast to all
        ctx.actions.broadcast("counter-update", { count: newCount });
      },
    },
  },
});

Rooms

Use rooms to group connections:

app/wss/chat/events.ts
import { defineWssRoute } from "@lolyjs/core";

export default defineWssRoute({
  events: {
    "join-room": {
      handler: async (ctx) => {
        await ctx.actions.join(ctx.data.roomId);
        // Notify room
        ctx.actions.toRoom(ctx.data.roomId).emit("user-joined", {
          userId: ctx.user?.id,
        });
      },
    },
    
    "room-message": {
      handler: (ctx) => {
        // Send to all in room
        ctx.actions.toRoom(ctx.data.roomId).emit("message", {
          from: ctx.user?.id,
          text: ctx.data.text,
        });
      },
    },
  },
});

User Targeting

Send messages to specific users (works across instances in cluster mode):

app/wss/chat/events.ts
import { defineWssRoute } from "@lolyjs/core";

export default defineWssRoute({
  events: {
    "private-message": {
      handler: (ctx) => {
        // Send to specific user (works in cluster)
        ctx.actions.toUser(ctx.data.toUserId).emit("private-message", {
          from: ctx.user?.id,
          text: ctx.data.text,
        });
      },
    },
  },
});

Client-Side Usage

Connect to realtime namespaces from your client code:

import { lolySocket } from "@lolyjs/core/sockets";
import { useEffect, useState } from "react";
import { Socket } from "socket.io-client";

export default function ChatComponent() {
  const [socket, setSocket] = useState<Socket | null>(null);
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    // Connect to namespace with auth
    const ws = lolySocket("/chat", {
      auth: {
        token: "your-jwt-token", // For auth hook
      },
    });
    
    setSocket(ws);

    ws.on("connect", () => {
      console.log("Connected to chat namespace");
    });

    ws.on("message", (data) => {
      setMessages((prev) => [...prev, data]);
    });
    
    // Listen for framework errors
    ws.on("__loly:error", (error) => {
      console.error("Error:", error.code, error.message);
    });

    // Cleanup on unmount
    return () => {
      ws.close();
    };
  }, []);

  const sendMessage = (text: string) => {
    if (socket && socket.connected) {
      socket.emit("message", { text });
    }
  };

  return (
    <div>
      {messages.map((msg, i) => (
        <div key={i}>{msg.text}</div>
      ))}
      <button onClick={() => sendMessage("Hello!")}>Send</button>
    </div>
  );
}

Configuration

Configure realtime in loly.config.ts by exporting a config function:

loly.config.ts
import { ServerConfig } from "@lolyjs/core";

export const config = (env: string): ServerConfig => {
  return {
    // ... other server configs
    bodyLimit: "1mb",
    corsOrigin: env === "production" 
      ? ["https://yourdomain.com"] 
      : "*",
    
    realtime: {
      enabled: true,
      
      // Socket.IO settings
      path: "/wss",
      transports: ["websocket", "polling"],
      
      // Security
      allowedOrigins: env === "production" 
        ? ["https://yourdomain.com"] 
        : "*",
      
      // Scaling (multi-instance)
      scale: {
        mode: "cluster", // or "single"
        adapter: {
          name: "redis",
          url: process.env.REDIS_URL!,
        },
        stateStore: {
          name: "redis", // or "memory"
          url: process.env.REDIS_URL!,
          prefix: "loly:rt:",
        },
      },
      
      // Rate limiting
      limits: {
        connectionsPerIp: 20,
        eventsPerSecond: 30,
        burst: 60,
      },
    },
  };
};

Error Handling

The framework emits structured errors:

socket.on("__loly:error", (error) => {
  console.error(error.code);    // "BAD_PAYLOAD", "RATE_LIMIT", "FORBIDDEN", etc.
  console.error(error.message); // Descriptive message
  console.error(error.details); // Additional details (optional)
  console.error(error.requestId); // ID for debugging
});

Error Codes:

  • BAD_PAYLOAD - Schema validation failed
  • RATE_LIMIT - Rate limit exceeded
  • FORBIDDEN - Guard check failed
  • UNAUTHORIZED - Authentication required