TypeScript Client
Using @grove-js/crdt for browser-based CRDT sync with HTTP pull/push, SSE streaming, client-side merge, and React hooks.
The @grove-js/crdt TypeScript package provides a full CRDT client for browser and Node.js applications. It mirrors the Go wire format exactly, enabling cross-language sync between Go servers and JavaScript clients.
Installation
npm install @grove-js/crdtReact hooks require React 18+ as a peer dependency:
npm install reactCore Modules
The package exports from two entry points:
// Core: client, store, merge, clock, stream, presence
import { CRDTClient, CRDTStore, HybridClock, PresenceManager } from "@grove-js/crdt";
// Plugin interfaces (for custom implementations)
import type { Transport, StreamTransport, AuthProvider, StorageAdapter } from "@grove-js/crdt";
// React hooks
import {
CRDTProvider, useDocument, useCollection,
usePresence, useDocumentPresence,
} from "@grove-js/crdt/react";CRDTClient
The HTTP client for pull/push sync with the Go server:
import { CRDTClient } from "@grove-js/crdt";
const client = new CRDTClient({
baseURL: "https://api.example.com/sync",
nodeID: "browser-1",
tables: ["documents", "comments"],
headers: {
Authorization: "Bearer <token>",
},
});
// Pull changes from the server.
const { changes, latestHLC } = await client.pull();
// Push local changes to the server.
const pushResult = await client.push(pendingChanges);
// Create an SSE stream instance.
const stream = client.stream({ reconnectDelay: 5000 });Configuration
| Option | Type | Description |
|---|---|---|
baseURL | string | Sync server URL. Required when no custom transport is provided. |
nodeID | string | Unique client identifier |
tables | string[] | Tables to sync |
headers | Record<string, string> | Custom headers (used by built-in HttpTransport) |
fetch | typeof fetch | Custom fetch implementation (used by built-in HttpTransport) |
transport | Transport | Custom transport for pull/push operations. When provided, baseURL is not required. |
streamTransport | StreamTransport | Custom stream transport for real-time subscriptions |
auth | AuthProvider | Auth provider for dynamic header injection (e.g., JWT refresh) |
storage | StorageAdapter | Storage adapter for persistence (IndexedDB, localStorage, etc.) |
presence | PresenceConfig | Presence configuration. Set { heartbeatInterval: 10000 } to enable. |
Pluggable Architecture
The client supports pluggable transports, auth, and storage. Use the built-in defaults or provide your own implementations.
Transport Interface
interface Transport {
pull(req: PullRequest): Promise<PullResponse>;
push(req: PushRequest): Promise<PushResponse>;
updatePresence?(update: PresenceUpdate): Promise<void>; // Optional
getPresence?(topic: string): Promise<PresenceState[]>; // Optional
}The built-in HttpTransport sends JSON POST requests to /pull, /push, and /presence. For custom backends (WebSocket, gRPC, etc.), implement the Transport interface:
import type { Transport } from "@grove-js/crdt";
const wsTransport: Transport = {
async pull(req) { /* ... send via WebSocket ... */ },
async push(req) { /* ... send via WebSocket ... */ },
};
const client = new CRDTClient({
nodeID: "browser-1",
transport: wsTransport,
});StreamTransport Interface
Extends Transport with real-time subscriptions:
interface StreamTransport extends Transport {
subscribe(config: StreamConfig): StreamSubscription;
}The built-in HttpStreamTransport provides both HTTP pull/push and SSE streaming.
AuthProvider
For dynamic header injection (e.g., JWT token refresh):
interface AuthProvider {
getHeaders(): Record<string, string> | Promise<Record<string, string>>;
}
// Built-in static implementation
import { StaticAuthProvider } from "@grove-js/crdt";
const auth = new StaticAuthProvider({ Authorization: "Bearer <token>" });
// Custom JWT refresh
const jwtAuth: AuthProvider = {
async getHeaders() {
const token = await refreshTokenIfNeeded();
return { Authorization: `Bearer ${token}` };
},
};
const client = new CRDTClient({
baseURL: "/api/sync",
nodeID: "browser-1",
auth: jwtAuth,
});StorageAdapter
For persisting CRDT state to IndexedDB, localStorage, or other backends:
interface StorageAdapter {
loadState(): Promise<Map<string, Map<string, DocumentState>>>;
saveDocument(table: string, pk: string, doc: DocumentState): Promise<void>;
deleteDocument(table: string, pk: string): Promise<void>;
loadPendingChanges(): Promise<ChangeRecord[]>;
savePendingChanges(changes: ChangeRecord[]): Promise<void>;
}The built-in MemoryStorage is a no-op default. Implement StorageAdapter for offline persistence.
CRDTStore
In-memory state management with dirty tracking and subscriptions:
import { CRDTStore, HybridClock } from "@grove-js/crdt";
const clock = new HybridClock("browser-1");
const store = new CRDTStore("browser-1", clock);
// Apply remote changes from a pull.
store.applyChanges(changes);
// Read a document.
const doc = store.getDocument<Document>("documents", "doc-1");
// { id: "doc-1", title: "Hello", viewCount: 42, tags: ["go", "crdt"] }
// Read a collection.
const docs = store.getCollection<Document>("documents");
// Make local changes.
store.setField("documents", "doc-1", "title", "Updated Title");
store.incrementCounter("documents", "doc-1", "viewCount", 1);
store.addToSet("documents", "doc-1", "tags", "typescript");
// Get pending changes for push.
const pending = store.getPendingChanges();
await client.push(pending);
store.clearPendingChanges();Mutations
| Method | Description |
|---|---|
setField(table, pk, field, value) | Set a LWW field value |
incrementCounter(table, pk, field, delta) | Increment a counter |
decrementCounter(table, pk, field, delta) | Decrement a counter |
addToSet(table, pk, field, ...elements) | Add elements to a set |
removeFromSet(table, pk, field, ...elements) | Remove elements from a set |
deleteDocument(table, pk) | Tombstone a document |
Subscriptions
The store supports fine-grained subscriptions compatible with React's useSyncExternalStore:
// Subscribe to all changes.
const unsub = store.subscribe(() => {
console.log("State changed");
});
// Subscribe to a specific document.
const unsub = store.subscribeDocument("documents", "doc-1", () => {
console.log("Document changed");
});
// Subscribe to an entire table.
const unsub = store.subscribeCollection("documents", () => {
console.log("Collection changed");
});Client-Side Merge
The package includes pure functions matching the Go merge algorithms:
import { mergeLWW, mergeCounter, counterValue, mergeSet, setElements } from "@grove-js/crdt";
// LWW merge: higher HLC wins.
const winner = mergeLWW(localValue, remoteValue);
// Counter merge: per-node max.
const merged = mergeCounter(localCounter, remoteCounter);
const value = counterValue(merged); // sum(inc) - sum(dec)
// Set merge: add-wins union.
const mergedSet = mergeSet(localSet, remoteSet);
const elements = setElements(mergedSet); // active elementsHybridClock
The TypeScript HLC uses Date.now() * 1_000_000 to produce nanosecond timestamps compatible with Go:
import { HybridClock, hlcCompare, hlcAfter } from "@grove-js/crdt";
const clock = new HybridClock("browser-1");
// Generate a new HLC.
const hlc = clock.now();
// { ts: 1706000000000000000, c: 0, node: "browser-1" }
// Update from a remote HLC.
clock.update(remoteHLC);
// Compare two HLCs.
const cmp = hlcCompare(a, b); // -1, 0, or 1
const isAfter = hlcAfter(a, b); // booleanSSE Stream
The CRDTStream class uses fetch() + ReadableStream instead of EventSource to support custom headers:
import { CRDTStream } from "@grove-js/crdt";
const stream = new CRDTStream(
"https://api.example.com/sync",
{ tables: ["documents"], reconnectDelay: 5000 },
{ Authorization: "Bearer <token>" }
);
stream.on((event) => {
if (event.type === "change") {
store.applyChanges([event.data]);
} else if (event.type === "changes") {
store.applyChanges(event.data);
}
});
stream.connect();React Hooks
Import from the ./react sub-export:
import {
CRDTProvider,
useDocument,
useCollection,
useLWW,
useCounter,
useSet,
useSyncStatus,
useStream,
} from "@grove-js/crdt/react";CRDTProvider
Wraps your app with the CRDT context:
function App() {
return (
<CRDTProvider config={{
baseURL: "https://api.example.com/sync",
nodeID: "browser-1",
tables: ["documents"],
headers: { Authorization: "Bearer <token>" },
}}>
<DocumentList />
</CRDTProvider>
);
}The provider automatically:
- Creates a
HybridClock,CRDTClient, andCRDTStore - Pulls initial data on mount
- Connects the SSE stream for real-time updates
- Provides a
sync()function for manual sync
useDocument
Subscribe to a single document with update and delete operations:
function DocumentEditor({ id }: { id: string }) {
const { data, loading, update, remove } = useDocument<Document>("documents", id);
if (loading) return <div>Loading...</div>;
if (!data) return <div>Not found</div>;
return (
<div>
<input
value={data.title}
onChange={(e) => update("title", e.target.value)}
/>
<button onClick={remove}>Delete</button>
</div>
);
}useCollection
Subscribe to an entire table:
function DocumentList() {
const { items, loading, create } = useCollection<Document>("documents");
return (
<div>
{items.map((doc) => (
<DocumentEditor key={doc.id} id={doc.id} />
))}
<button onClick={() => create("new-id", { title: "Untitled" })}>
New Document
</button>
</div>
);
}useLWW
Subscribe to a single LWW field:
function TitleEditor({ docId }: { docId: string }) {
const { value, set } = useLWW<string>("documents", docId, "title");
return (
<input value={value ?? ""} onChange={(e) => set(e.target.value)} />
);
}useCounter
Subscribe to a counter field:
function ViewCounter({ docId }: { docId: string }) {
const { value, increment } = useCounter("documents", docId, "viewCount");
return (
<div>
<span>Views: {value}</span>
<button onClick={() => increment(1)}>+1</button>
</div>
);
}useSet
Subscribe to a set field:
function TagEditor({ docId }: { docId: string }) {
const { elements, add, remove, has } = useSet<string>("documents", docId, "tags");
return (
<div>
{elements.map((tag) => (
<span key={tag}>
{tag} <button onClick={() => remove(tag)}>x</button>
</span>
))}
<button onClick={() => add("new-tag")}>Add Tag</button>
</div>
);
}useSyncStatus
Monitor sync state:
function SyncIndicator() {
const { status, lastSyncTime, pendingCount, sync } = useSyncStatus();
return (
<div>
<span>Status: {status}</span>
<span>Pending: {pendingCount}</span>
<button onClick={sync} disabled={status === "syncing"}>
Sync Now
</button>
</div>
);
}useStream
Control the SSE stream connection:
function StreamControl() {
const { connected, disconnect, reconnect } = useStream();
return (
<div>
<span>{connected ? "Connected" : "Disconnected"}</span>
<button onClick={connected ? disconnect : reconnect}>
{connected ? "Disconnect" : "Reconnect"}
</button>
</div>
);
}usePresence
Subscribe to presence for any topic. See Presence & Awareness for the full guide.
import { usePresence } from "@grove-js/crdt/react";
interface UserPresence {
name: string;
cursor: { x: number; y: number } | null;
isTyping: boolean;
}
function CollaborativeEditor({ roomId }: { roomId: string }) {
const { others, updateMyPresence, leave } = usePresence<UserPresence>(roomId);
return (
<div>
{others.map((peer) => (
<div key={peer.node_id}>
{peer.data.name} {peer.data.isTyping ? "is typing..." : ""}
</div>
))}
<textarea
onKeyDown={() => updateMyPresence({ isTyping: true })}
onBlur={() => updateMyPresence({ isTyping: false, cursor: null })}
/>
</div>
);
}The hook automatically leaves the topic when the component unmounts.
useDocumentPresence
Convenience wrapper that constructs the topic as "table:pk":
import { useDocumentPresence } from "@grove-js/crdt/react";
function DocumentEditor({ docId }: { docId: string }) {
const { others, updateMyPresence } = useDocumentPresence<{
name: string;
color: string;
}>("documents", docId);
useEffect(() => {
updateMyPresence({ name: "Alice", color: "#3b82f6" });
}, []);
return (
<div>
<div className="active-users">
{others.map((p) => (
<span key={p.node_id} style={{ color: p.data.color }}>
{p.data.name}
</span>
))}
</div>
{/* ... editor content ... */}
</div>
);
}Full Example
A collaborative document editor with real-time CRDT sync:
import { CRDTProvider, useDocument, useCollection, useSyncStatus } from "@grove-js/crdt/react";
interface Document {
id: string;
title: string;
viewCount: number;
tags: string[];
}
function App() {
return (
<CRDTProvider config={{
baseURL: "/api/sync",
nodeID: `browser-${crypto.randomUUID()}`,
tables: ["documents"],
}}>
<SyncBar />
<DocumentList />
</CRDTProvider>
);
}
function SyncBar() {
const { status, pendingCount, sync } = useSyncStatus();
return (
<header>
<span>{status} | {pendingCount} pending</span>
<button onClick={sync}>Sync</button>
</header>
);
}
function DocumentList() {
const { items, create } = useCollection<Document>("documents");
return (
<div>
{items.map((doc) => (
<DocumentCard key={doc.id} id={doc.id} />
))}
<button onClick={() => create(crypto.randomUUID(), { title: "Untitled" })}>
New
</button>
</div>
);
}
function DocumentCard({ id }: { id: string }) {
const { data, update, remove } = useDocument<Document>("documents", id);
if (!data) return null;
return (
<div>
<input
value={data.title}
onChange={(e) => update("title", e.target.value)}
/>
<span>Views: {data.viewCount}</span>
<button onClick={remove}>Delete</button>
</div>
);
}