Real-time chat, video/audio calls, and end-to-end encryption built on LumenJS Socket.IO. The communication module provides a complete messaging infrastructure: conversations, typing indicators, presence, read receipts, WebRTC signaling, and Signal Protocol-inspired E2E encryption, all wired together with a single integration.
Enable the communication integration in your config and export a socket handler from any page:
// lumenjs.config.ts export default { title: 'My App', integrations: ['socketio', 'communication'], };
Create a page that uses the communication handler:
// pages/chat/[conversationId].ts import { LitElement, html } from 'lit'; import { createCommunicationHandler } from '@nuraly/lumenjs/dist/communication/server.js'; // Server-side socket handler. Wires up all communication events export const socket = createCommunicationHandler(); export class ChatPage extends LitElement { static properties = { event: { type: String }, data: { type: Object }, }; event = ''; data: any = null; sendMessage(text: string) { this.emit('message:send', { conversationId: this.params.conversationId, content: text, type: 'text', }); } render() { return html`<p>${this.event}</p>`; } }
createCommunicationHandler() returns a standard LumenJS socket handler. It registers listeners for all chat, call, signaling, and encryption events automatically. The client sends events via this.emit() and receives server pushes as individual properties (each key from push() is spread onto the component).
You can customize the handler with options:
import { createCommunicationHandler } from '@nuraly/lumenjs/dist/communication/server.js'; import { useDb } from '@nuraly/lumenjs/db'; export const socket = createCommunicationHandler({ // Persist messages to SQLite db: useDb(), // Custom user ID extraction from socket handshake getUserId(headers, query) { return headers['x-user-id'] || query.userId; }, // Communication config overrides config: { maxMessageLength: 5000, readReceipts: true, typingIndicators: true, encryption: { enabled: true, oneTimePreKeyCount: 100 }, }, });
Send messages from the client using this.emit('message:send', ...). The server broadcasts new messages to all participants in the conversation room.
Sending a message:
// Client-side, inside your page component this.emit('message:send', { conversationId: 'conv-123', content: 'Hello, world!', type: 'text', // 'text' | 'image' | 'file' | 'audio' | 'system' replyTo: 'msg-456', // optional, reply to another message attachment: { // optional, file attachment url: '/uploads/photo.jpg', mimeType: 'image/jpeg', fileName: 'photo.jpg', fileSize: 204800, width: 1920, height: 1080, thumbnailUrl: '/uploads/photo-thumb.jpg', }, });
Receiving messages:
When the server broadcasts a new message, the event and data properties are updated on your component:
// push() sends: { event: 'message:new', data: Message } updated(changed) { if (changed.has('data') && this.event === 'message:new') { const message = this.data; // message.id, message.senderId, message.content, message.type, // message.createdAt, message.attachment, message.status, message.readBy this.messages = [...this.messages, message]; } }
Message types:
| Type | Description |
|---|---|
text | Plain text message |
image | Image with optional thumbnail (uses attachment) |
file | Generic file upload (uses attachment) |
audio | Audio clip or voice message (uses attachment.duration) |
system | System-generated message (user joined, left, etc.) |
Message status lifecycle: sending → sent → delivered → read. A message may also reach failed if delivery fails.
Conversations are managed via both socket events (join/leave) and the REST API (create, list, search).
Joining a conversation room (socket):
// Client joins a conversation room to receive its events this.emit('conversation:join', { conversationId: 'conv-123' }); // Leave when done this.emit('conversation:leave', { conversationId: 'conv-123' });
Creating a conversation (REST):
// api/conversations.ts import { createCommunicationApiHandlers } from '@nuraly/lumenjs/dist/communication/server.js'; import { useDb } from '@nuraly/lumenjs/db'; const comm = createCommunicationApiHandlers(useDb()); export function GET(req) { const userId = req.headers['x-user-id']; return comm.getConversations(userId, { limit: Number(req.query.limit) || 50, offset: Number(req.query.offset) || 0, }); } export function POST(req) { return comm.createConversation({ type: req.body.type, // 'direct' | 'group' name: req.body.name, // optional group name participantIds: req.body.participantIds, }); }
Conversation type:
| Field | Type | Description |
|---|---|---|
id | string | Unique conversation ID |
type | 'direct' | 'group' | 1:1 or multi-participant |
name | string? | Group name (optional for direct) |
participants | Participant[] | List of participants with roles |
lastMessage | Message? | Most recent message preview |
unreadCount | number | Unread messages for current user |
createdAt | string | ISO 8601 timestamp |
updatedAt | string | Last activity timestamp |
Typing indicators are broadcast to other participants in the conversation. They auto-expire after 5 seconds of inactivity to handle cases where the client fails to send a typing:stop event.
// Start typing, broadcast to other participants this.emit('typing:start', { conversationId: 'conv-123' }); // Stop typing, or let the 5-second auto-expiry handle it this.emit('typing:stop', { conversationId: 'conv-123' });
Listening for typing updates:
// push() sends: { event: 'typing:update', data: TypingIndicator } updated(changed) { if (changed.has('data') && this.event === 'typing:update') { const { conversationId, userId, isTyping } = this.data; if (isTyping) { this.typingUsers.add(userId); } else { this.typingUsers.delete(userId); } this.requestUpdate(); } }
typing:stop (e.g., they close the tab), the server automatically broadcasts isTyping: false after the timeout. Sending a message also clears the typing indicator automatically.
Track whether users are online, offline, away, or busy. Presence is updated automatically on connect/disconnect, and can be set manually.
// Update your own presence status this.emit('presence:update', { status: 'away' }); // 'online' | 'offline' | 'away' | 'busy'
Listening for presence changes:
// push() sends: { event: 'presence:changed', data: PresenceUpdate } updated(changed) { if (changed.has('data') && this.event === 'presence:changed') { const { userId, status, lastSeen } = this.data; this.presenceMap.set(userId, { status, lastSeen }); this.requestUpdate(); } }
Presence statuses:
| Status | Description |
|---|---|
online | User has at least one active socket connection (set automatically on connect) |
offline | User has no active connections (set automatically when last socket disconnects) |
away | User is idle, set manually by the client |
busy | User prefers not to be disturbed, set manually by the client |
offline when the last socket for that user disconnects.
Mark messages as read and receive read receipt updates for all participants in a conversation.
// Mark a message as read this.emit('message:read', { conversationId: 'conv-123', messageId: '42', });
Listening for read receipt updates:
// push() sends: { event: 'read-receipt:update', data: { conversationId, messageId, readBy } } updated(changed) { if (changed.has('data') && this.event === 'read-receipt:update') { const { messageId, readBy } = this.data; // readBy: { userId: string, readAt: string } const msg = this.messages.find(m => m.id === messageId); if (msg) msg.readBy.push(readBy); } }
When a database is configured, read receipts are persisted to the read_receipts table using INSERT OR IGNORE to prevent duplicates. The message status is also updated to 'read'.