livetexts
v0.1.4
Published
Self-hosted real-time collaborative rooms — a Liveblocks alternative on Cloudflare Workers
Downloads
521
Maintainers
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
- Client-side persistence — IndexedDB via
y-indexeddb, built in by default. Offline edits survive tab closes and sync back on reconnect
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.
Offline edits survive tab closes. The SDK persists the Yjs document to IndexedDB automatically. When the user reopens the tab and reconnects, their offline edits are still there and sync to the server seamlessly — no extra setup required.
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.
How it compares to Liveblocks
An honest, feature-by-feature comparison.
| Feature | livetexts | Liveblocks |
|---------|-----------|------------|
| Real-time presence | ✅ | ✅ |
| CRDT storage (key-value) | ✅ | ✅ |
| Collaborative text (Y.Text) | ✅ | ✅ |
| Offline sync & merge | ✅ | ✅ |
| Auto-reconnect with backoff | ✅ | ✅ |
| JWT auth | ✅ | ✅ |
| Room access control (join codes) | ✅ | ✅ (via access tokens) |
| SQLite persistence (server) | ✅ per-room | ✅ managed |
| Client-side persistence (IndexedDB) | ✅ built in | ✅ |
| Self-hostable | ✅ you own everything | ❌ SaaS only |
| Pricing | Free (you pay Cloudflare) | Free tier → paid plans |
| Hosted option (no deploy needed) | 🔜 coming soon | ✅ |
| React hooks library | ❌ bring your own | ✅ @liveblocks/react |
| Conflict-free rich text (Lexical/TipTap) | ❌ | ✅ |
| Storage history & time travel | ❌ | ✅ |
| Notifications & webhooks | ❌ | ✅ |
| Comments & threads | ❌ | ✅ |
| Dashboard & analytics | ❌ | ✅ |
| Support & SLA | ❌ | ✅ (paid plans) |
livetexts covers the core primitives — presence, CRDT storage, collaborative text, and offline sync — the things you actually need to build a real-time collaborative feature. Everything else on that list is either a nice-to-have or a product built on top of those primitives. It is a side project built on weekends, and it will keep growing.
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
