livetexts
v0.2.0
Published
Self-hosted real-time collaborative rooms for small apps, on Cloudflare Workers
Maintainers
Readme
livetexts
A self-hosted, open-source toolkit for real-time collaborative apps — presence, CRDT storage, and offline sync, running on Cloudflare Workers + Durable Objects.
This started as a learning project to understand how tools like Liveblocks work under the hood, and grew into something usable for small-scale collaborative apps. It is not a drop-in Liveblocks replacement — no comments/threads, no storage history, no dashboard, no SLA. If you need those, use Liveblocks. If you want to run the core sync primitives yourself on your own Cloudflare account for a small project, this covers that.
Live demo (sync server): crdt-rooms-demo.vercel.app · Source Live demo (collaborative editor): texty.giteshsarvaiya.xyz
It is just a learning project as of now, open for contributions and discussions.
Not to be confused with: Liveblocks has their own product called LiveText — a Storage data type for collaborative text editing in their platform. The name similarity is coincidental. This project (livetexts) is unrelated to it — a separate, independent, self-hosted sync layer.
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.
It's intentionally scoped small: the core sync primitives (presence, CRDT storage, offline sync), not a full platform. That makes it a reasonable fit for side projects, prototypes, and small-scale collaborative apps where you don't want to pay for or depend on a managed service — but it's not aiming to replace Liveblocks for teams that need the surrounding product (comments, notifications, history, support).
This is a side project I build on weekends, built mainly to understand this space deeply. It'll keep growing, but treat it as a learning-driven project rather than a maintained product with guarantees.
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 hooks — lib/liverooms.config.ts
React hooks ship built in (livetexts/react) — no need to hand-roll a context. createClient handles room reuse across components (safe under React Strict Mode's double-mount), and createRoomContext returns a fully-typed set of hooks for a given presence/storage shape.
// lib/liverooms.config.ts
import { createClient } from 'livetexts'
import { createRoomContext } from 'livetexts/react'
type Presence = { name: string; cursor?: { x: number; y: number } }
type Storage = { content: string }
const client = createClient({
serverUrl: process.env.NEXT_PUBLIC_CRDT_SERVER_URL!,
authEndpoint: '/api/auth',
})
export const {
RoomProvider,
useOthers,
useSelf,
useUpdateMyPresence,
useStorage,
useStatus,
useHistory, // undo/redo over shared storage, via Y.UndoManager
} = createRoomContext<Presence, Storage>(client)// app/room/[roomId]/page.tsx
'use client'
import { RoomProvider, useOthers, useUpdateMyPresence } from '@/lib/liverooms.config'
function Editor() {
const others = useOthers()
const updateMyPresence = useUpdateMyPresence()
// ...
}
export default function RoomPage({ roomId, userId }: { roomId: string; userId: string }) {
return (
<RoomProvider roomId={roomId} userId={userId} initialPresence={{ name: 'Alice' }}>
<Editor />
</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, "published": false }
→ 403 { "error": "Invalid join code" }
→ 404 { "error": "Room not found" }List all rooms (admin)
GET /admin/rooms
x-admin-secret: <your SECRET_KEY wrangler secret>
→ 200 { "rooms": [
{ "roomId": "my-room", "createdBy": "alice", "createdAt": "...", "published": false, "publishedAt": undefined }
] }
→ 401 { "error": "Unauthorized" }For your own operational visibility only — lists every room in the KV store. Gate it behind your SECRET_KEY, and don't expose it publicly.
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
Offline convergence is the hardest part of building this kind of sync layer — see blog/auth-divergence.md for notes on how this implementation compares to Liveblocks' approach.
How it compares to Liveblocks
An honest, feature-by-feature comparison — mostly to be clear about what this project is not.
| 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 | ✅ livetexts/react | ✅ @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. That's enough to build a real-time feature into a small app. Everything else on that list is product surface Liveblocks has built on top of those primitives over years, with a team and paying customers behind it — it's out of scope for a weekend project, and this README won't pretend otherwise.
How it compares to PartyKit
PartyKit (acquired by Cloudflare in April 2024) and livetexts share the same underlying building blocks — Cloudflare Workers, Durable Objects, Yjs, and WebSockets. The difference is the layer each operates at.
| | livetexts | PartyKit |
|---|---|---|
| What it is | Sync infrastructure + client SDK | Developer platform + hosting |
| Abstraction level | Raw Durable Objects, no magic | Abstracts DOs behind a simple API |
| Deployment | You deploy to your own Cloudflare account | PartyKit/Cloudflare hosts it for you |
| CLI | wrangler deploy | npx partykit deploy |
| Yjs integration | Manual — you wire it yourself | Built-in helpers |
| Framework integrations | ❌ bring your own | ✅ React, Vue, SvelteKit |
| Self-hostable | ✅ fully | ❌ platform-dependent |
| Vendor lock-in | None — pure Cloudflare primitives | Tied to PartyKit/Cloudflare platform |
The key difference: PartyKit makes real-time accessible to every developer by abstracting the complexity. livetexts removes the abstraction and operates directly at the primitive level — one step closer to the metal. Building livetexts was an exercise in understanding exactly what PartyKit and Liveblocks are doing underneath.
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.
Optimizations
A running log of performance and correctness improvements made to the sync pipeline, storage layer, and message handling — including benchmarks and trade-off notes.
License
MIT
