Package Exports
- livetexts
- livetexts/server
Readme
livetexts
A self-hosted, open-source alternative to Liveblocks — real-time collaborative rooms with presence, CRDT storage, and offline sync, running on Cloudflare Workers + Durable Objects.
Live demo: crdt-rooms-demo.vercel.app · Source
Philosophy
livetexts is self-hosted by design. You deploy the sync server to your own Cloudflare account — you own your infrastructure, your data, and your costs. There is no shared server, no usage limits, and no vendor lock-in.
This is the core difference from Liveblocks: instead of paying for a managed service, you run the exact same architecture yourself on Cloudflare's global edge network, for a fraction of the cost.
A hosted version — where you can get started without deploying anything — is something I plan to add in the future. This is a side project I build on weekends, so it will get there eventually.
How it works
Browser (livetexts client SDK)
↓ WebSocket + JWT auth
Cloudflare Worker (router, auth verification, room creation)
↓
Room Durable Object (one per room — presence, Yjs CRDT doc, SQLite persistence)
↓ fan-out
All clients in room- One Durable Object per room — globally consistent, lives at the Cloudflare edge
- Yjs for CRDT storage — offline edits merge correctly on reconnect
- JWT auth — public rooms (public key) or private rooms (your backend issues tokens)
- SQLite persistence — room state survives DO hibernation and redeploys
- WebSocket hibernation — DO sleeps when idle, zero idle cost
Self-hosting the server
1. Clone and install
git clone https://github.com/giteshsarvaiya/crdt-rooms
cd crdt-rooms
npm install2. Set secrets
# Long random string — signs and verifies JWTs
wrangler secret put SECRET_KEY
# generate one: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Short public identifier for public rooms
wrangler secret put PUBLIC_KEY
# e.g. pk_myapp_prod3. Create a KV namespace (room access control)
wrangler kv namespace create "ROOMS"
# Wrangler adds it to wrangler.jsonc automatically4. Deploy
npm run deploy
# → https://crdt-rooms.<your-subdomain>.workers.devUsing the SDK
Install
npm install livetexts yjsClient SDK — vanilla JS / any framework
import { Room } from 'livetexts'
const room = new Room('wss://crdt-rooms.yourname.workers.dev', 'my-room', {
userId: 'alice',
joinCode: 'ABC12345', // from room creation
authEndpoint: '/api/auth', // your backend — SDK calls this automatically
})
await room.connect() // resolves when full room state arrives
// Presence
room.updatePresence({ cursor: { x: 100, y: 200 }, name: 'Alice' })
room.subscribe('others', (others) => {
others.forEach(u => console.log(u.userId, u.data))
})
// Storage (key-value, persisted via Yjs)
room.storage.set('count', 42)
room.storage.get('count') // 42
room.subscribe('storage', (storage) => console.log(storage.toJSON()))
// Collaborative text
import * as Y from 'yjs'
const yText = room.doc.getText('content')
yText.observe(() => console.log(yText.toString()))
// Connection status
room.subscribe('status', (s) => console.log(s)) // 'connecting' | 'connected' | 'disconnected'
// Disconnect
room.disconnect()Auto-reconnect is built in with exponential backoff (1s → 30s). On reconnect, the SDK performs a bidirectional Yjs state vector sync — it gets what it missed and pushes its offline edits back.
Public rooms (no backend needed)
const room = new Room('wss://crdt-rooms.yourname.workers.dev', 'my-room', {
userId: 'alice',
publicKey: 'pk_myapp_prod', // your PUBLIC_KEY wrangler secret
})Complete Next.js integration
This is the full end-to-end flow. See the demo app for the working source.
1. Environment variables
# .env.local
CRDT_SECRET_KEY=<same value as your SECRET_KEY wrangler secret>
CRDT_WORKER_URL=https://crdt-rooms.yourname.workers.dev
NEXT_PUBLIC_CRDT_SERVER_URL=wss://crdt-rooms.yourname.workers.dev
NEXT_PUBLIC_CRDT_WORKER_URL=https://crdt-rooms.yourname.workers.dev2. Auth endpoint — app/api/auth/route.ts
The SDK calls this automatically when connecting to a private room.
import { NextRequest, NextResponse } from 'next/server'
import { CrdtRooms } from 'livetexts/server'
const rooms = new CrdtRooms({ secret: process.env.CRDT_SECRET_KEY! })
export async function POST(request: NextRequest) {
const { room, userId, joinCode } = await request.json()
// 1. Verify the join code against the Worker's KV store
const verify = await fetch(
`${process.env.CRDT_WORKER_URL}/rooms/${room}/verify?code=${joinCode}`
)
if (!verify.ok) {
return NextResponse.json({ error: 'Invalid join code' }, { status: 403 })
}
// 2. Optionally check your own DB here — is this userId allowed in this room?
// 3. Issue a signed JWT
const session = rooms.prepareSession(userId)
session.allow(room, session.FULL_ACCESS)
const { token } = await session.authorize()
return NextResponse.json({ token })
}3. Create a room — app/api/rooms/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const { roomId, createdBy } = await request.json()
const res = await fetch(`${process.env.CRDT_WORKER_URL}/rooms`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ roomId, createdBy }),
})
const data = await res.json()
return NextResponse.json(data, { status: res.status })
}4. React context + hooks — lib/RoomContext.tsx
'use client'
import { createContext, useContext, useEffect, useRef, useState } from 'react'
import { Room, OtherUser, RoomStorage, ConnectionStatus } from 'livetexts'
const RoomContext = createContext<any>(null)
export function RoomProvider({ children, serverUrl, roomId, userId, userName, joinCode }) {
const roomRef = useRef(null)
const [others, setOthers] = useState([])
const [storage, setStorage] = useState(null)
const [status, setStatus] = useState('disconnected')
useEffect(() => {
const room = new Room(serverUrl, roomId, { userId, joinCode, authEndpoint: '/api/auth' })
roomRef.current = room
const u1 = room.subscribe('others', (all) => setOthers(all.filter(u => u.userId !== userId)))
const u2 = room.subscribe('storage', setStorage)
const u3 = room.subscribe('status', setStatus)
room.connect().then(() => room.updatePresence({ name: userName }))
return () => { u1(); u2(); u3(); room.disconnect() }
}, [serverUrl, roomId, userId, userName, joinCode])
if (!roomRef.current) return null
return (
<RoomContext.Provider value={{ room: roomRef.current, others, storage, status, self: { userId, name: userName } }}>
{children}
</RoomContext.Provider>
)
}
export const useRoom = () => useContext(RoomContext).room
export const useOthers = () => useContext(RoomContext).others
export const useStorage = () => useContext(RoomContext).storage
export const useStatus = () => useContext(RoomContext).status
export const useSelf = () => useContext(RoomContext).self5. Room page — app/room/[roomId]/page.tsx
'use client'
import { use } from 'react'
import { useSearchParams } from 'next/navigation'
import { RoomProvider } from '@/lib/RoomContext'
export default function RoomPage({ params }) {
const { roomId } = use(params)
const searchParams = useSearchParams()
const name = searchParams.get('name') || 'Anonymous'
const joinCode = searchParams.get('code') || ''
const userId = getUserId() // stable per-session ID from sessionStorage
const serverUrl = process.env.NEXT_PUBLIC_CRDT_SERVER_URL
return (
<RoomProvider serverUrl={serverUrl} roomId={roomId} userId={userId} userName={name} joinCode={joinCode}>
<YourEditor />
</RoomProvider>
)
}Server SDK reference
import { CrdtRooms } from 'livetexts/server'
const rooms = new CrdtRooms({ secret: process.env.CRDT_SECRET_KEY })
const session = rooms.prepareSession(userId)
session.allow(roomId, session.FULL_ACCESS) // read + write
session.allow(roomId, session.READ_ACCESS) // read only
const { token } = await session.authorize() // returns signed JWTRoom access control API
The Worker exposes two HTTP endpoints for room management:
Create a room
POST /rooms
{ "roomId": "my-room", "createdBy": "alice" }
→ 200 { "roomId": "my-room", "joinCode": "ABC12345" }
→ 409 { "error": "Room already exists" }The joinCode is an 8-character random string. Share it (or the full URL) with anyone you want to invite.
Verify a join code
GET /rooms/my-room/verify?code=ABC12345
→ 200 { "valid": true }
→ 403 { "error": "Invalid join code" }
→ 404 { "error": "Room not found" }Offline sync
Works automatically. Flow on reconnect:
- Client sends its Yjs state vector — a compact summary of what it already has
- Server computes the diff and sends back only what the client missed
- Client applies the diff, then pushes its offline edits back using the server's state vector
- Both sides converge — no data lost, no conflicts
This is the core of what Liveblocks sells. See blog/auth-divergence.md for a detailed comparison of our implementation vs Liveblocks.
Architecture decisions
| Decision | Choice | Why |
|---|---|---|
| Runtime | Cloudflare Workers + Durable Objects | One DO per room, globally distributed — same as Liveblocks production |
| CRDT | Yjs | Battle-tested, handles text/map/array, great offline support |
| Persistence | DO built-in SQLite | Per-room, zero config, survives hibernation |
| Auth | JWT signed locally | No central authority — fully self-contained, works offline |
| WebSocket | Hibernation API | DO sleeps when idle — zero idle cost |
Local development
npm run dev
# Worker runs at http://localhost:8787Open test.html in two browser tabs to test presence, storage sync, and offline convergence without a frontend.
License
MIT