Server

Bun-native WebSocket server for real-time rooms, presence, storage, and Yjs collaboration. The @waits/lively-server package handles room lifecycle, authentication, CRDT storage, and Yjs document sync out of the box.

LivelyServer

The main entry point. Accepts a ServerConfig object and exposes .start() / .stop() for lifecycle control.

server.ts
import { LivelyServer } from "@waits/lively-server";
const server = new LivelyServer({
port: 1999,
path: "/rooms", // default
healthPath: "/health", // default
auth: myAuthHandler,
roomConfig: { cleanupTimeoutMs: 30_000 },
onJoin, onLeave, onMessage,
onStorageChange, initialStorage,
initialYjs, onYjsChange,
});
await server.start();
// server.port → 1999

All config fields are optional. With zero config, the server listens on a random port at /rooms/:roomId.

ServerConfig shape:

types.ts
interface ServerConfig {
port?: number;
path?: string; // WebSocket path prefix
healthPath?: string; // HTTP health endpoint
auth?: AuthHandler;
roomConfig?: RoomConfig;
onMessage?: OnMessageHandler;
onJoin?: OnJoinHandler;
onLeave?: OnLeaveHandler;
onStorageChange?: OnStorageChangeHandler;
initialStorage?: InitialStorageHandler;
initialYjs?: InitialYjsHandler;
onYjsChange?: OnYjsChangeHandler;
}

Authentication

Implement the AuthHandler interface to authenticate WebSocket upgrade requests. Return a user object or null to reject the connection.

types.ts
interface AuthHandler {
authenticate(
req: IncomingMessage
): Promise<{ userId: string; displayName: string } | null>;
}

Example — verify a JWT from the query string:

auth.ts
const auth: AuthHandler = {
async authenticate(req) {
const url = new URL(req.url!, `http://${req.headers.host}`);
const token = url.searchParams.get("token");
if (!token) return null;
const payload = await verifyJwt(token);
return { userId: payload.sub, displayName: payload.name };
}
};

Without an auth handler, the server falls back to userId and displayName query parameters on the WebSocket URL.

Room Callbacks

Hook into room events for logging, persistence, or custom logic. All callbacks are async-safe.

onJoin — fired when a user connects to a room:

type OnJoinHandler = (
roomId: string,
user: PresenceUser
) => void | Promise<void>;

onLeave — fired when a user disconnects:

type OnLeaveHandler = (
roomId: string,
user: PresenceUser
) => void | Promise<void>;

onMessage — custom messages not handled internally:

type OnMessageHandler = (
roomId: string,
senderId: string,
message: Record<string, unknown>
) => void | Promise<void>;

onStorageChange — fired after CRDT ops are applied:

type OnStorageChangeHandler = (
roomId: string,
ops: StorageOp[]
) => void | Promise<void>;

initialStorage — load persisted CRDT state when a room is created:

type InitialStorageHandler = (
roomId: string
) => Promise<SerializedCrdt | null>;

Example — persist storage to a database:

server.ts
const server = new LivelyServer({
async initialStorage(roomId) {
return await db.getStorage(roomId);
},
onStorageChange(roomId, ops) {
await db.saveOps(roomId, ops);
},
});

Yjs Support

The server manages a Y.Doc per room, syncing updates between clients automatically. Use these callbacks to persist and restore Yjs state.

initialYjs — load persisted Yjs state for a room:

type InitialYjsHandler = (
roomId: string
) => Promise<Uint8Array | null>;

onYjsChange — fired after a Yjs update is applied:

type OnYjsChangeHandler = (
roomId: string,
state: Uint8Array
) => void | Promise<void>;

Example — persist Yjs to file:

server.ts
import { writeFile, readFile } from "node:fs/promises";
const server = new LivelyServer({
async initialYjs(roomId) {
try {
return await readFile(`./data/${roomId}.yjs`);
} catch {
return null;
}
},
onYjsChange(roomId, state) {
await writeFile(`./data/${roomId}.yjs`, state);
},
});

Room (Server-Side)

Each room is created automatically when the first client connects. The LivelyServer exposes helper methods to interact with rooms from outside the WebSocket flow.

server-side room API
// Broadcast arbitrary data to a room
server.broadcastToRoom(roomId, data, excludeIds?);
// Mutate CRDT storage from server code
await server.mutateStorage(roomId, (root) => {
root.set("key", "value");
});
// Get connected users
const users = server.getRoomUsers(roomId);
// Set live state from server
server.setLiveState(roomId, "theme", "dark");
// Access the RoomManager directly
const manager = server.getRoomManager();

The RoomManager handles room creation, lookup, and automatic cleanup when rooms are empty (default: 30s timeout).

Types

All types are exported from @waits/lively-server.

imports
import type {
ServerConfig,
AuthHandler,
RoomConfig,
OnJoinHandler,
OnLeaveHandler,
OnMessageHandler,
OnStorageChangeHandler,
InitialStorageHandler,
InitialYjsHandler,
OnYjsChangeHandler,
Connection,
LiveStateEntry,
} from "@waits/lively-server";
interface RoomConfig {
cleanupTimeoutMs?: number; // ms before empty room is destroyed
maxConnections?: number; // cap per room
}
interface Connection {
ws: WebSocket;
user: PresenceUser;
location?: string;
metadata?: Record<string, unknown>;
onlineStatus: OnlineStatus;
lastActiveAt: number;
lastHeartbeat: number;
}
interface LiveStateEntry {
value: unknown;
timestamp: number;
userId: string;
}

Next Steps