JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 4
  • Score
    100M100P100Q43670F
  • License MIT

Self-hosted real-time collaborative rooms — a Liveblocks alternative on Cloudflare Workers

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
  • 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 install

2. 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_prod

3. Create a KV namespace (room access control)

wrangler kv namespace create "ROOMS"
# Wrangler adds it to wrangler.jsonc automatically

4. Deploy

npm run deploy
# → https://crdt-rooms.<your-subdomain>.workers.dev

Using the SDK

Install

npm install livetexts yjs

Client 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.dev

2. 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).self

5. 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 JWT

Room 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:

  1. Client sends its Yjs state vector — a compact summary of what it already has
  2. Server computes the diff and sends back only what the client missed
  3. Client applies the diff, then pushes its offline edits back using the server's state vector
  4. 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:8787

Open test.html in two browser tabs to test presence, storage sync, and offline convergence without a frontend.


License

MIT