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.2.0

Published

Self-hosted real-time collaborative rooms for small apps, on Cloudflare Workers

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 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 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 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, "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:

  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

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

Open 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.

→ optimizations.md


License

MIT