npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

livetexts

v0.1.4

Published

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

Downloads

521

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