Skip to content

Chahine-tech/meridian

Repository files navigation

Meridian Meridian

Self-hosted real-time CRDT sync server.
Open-source alternative to Liveblocks and PartyKit β€” no vendor lock-in, no merge conflicts.
Built on Effect TS β€” type-safe errors, composable async, runtime schema validation.

CI Rust 2024 meridian-sdk downloads meridian-react downloads MIT License


What is Meridian?

Meridian is a real-time CRDT sync server that runs self-hosted on native infrastructure or at the edge on Cloudflare Workers β€” same SDK, same protocol, two deployment targets.

You pick a CRDT type (counter, set, register, presence), apply operations from any client, and every client converges to the same value automatically β€” no locks, no last-write-wins bugs.

Quick start

# Start the server
MERIDIAN_SIGNING_KEY=$(openssl rand -hex 32) docker compose up -d

# Issue a token
curl -X POST http://localhost:3000/v1/namespaces/my-room/tokens \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"client_id": 1, "ttl_ms": 3600000}'

Usage

React

import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";
import { MeridianProvider, useAwareness, useGCounter } from "meridian-react";
import { Schema } from "effect";

const client = await Effect.runPromise(
  MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);

const CursorSchema = Schema.Struct({ x: Schema.Number, y: Schema.Number });

function Room() {
  const { value, increment } = useGCounter("gc:views");
  // Awareness: ephemeral cursor positions, not persisted
  const { peers, update } = useAwareness("cursors", CursorSchema);

  return <p>{value} views Β· {peers.length} peers live</p>;
}

function App() {
  return (
    <MeridianProvider client={client}>
      <Room />
    </MeridianProvider>
  );
}

TypeScript (framework-agnostic)

The SDK exposes a simple imperative API on top of an Effect-based core β€” use runPromise to bridge into your existing async code, or compose with Effect.gen for full type-safe pipelines.

import { MeridianClient } from "meridian-sdk";
import { Effect, Schema } from "effect";

const client = await Effect.runPromise(
  MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);

// Counters, registers, sets β€” all conflict-free
const views = client.gcounter("gc:views");
views.increment(1);
views.onChange(v => console.log("views:", v));

// Every handle has stream() β€” composable with Effect
import { Stream } from "effect";
await Effect.runPromise(
  views.stream().pipe(Stream.take(5), Stream.runForEach(v => Effect.log(`views: ${v}`)))
);

// Awareness β€” ephemeral cursors, not persisted, schema-validated at runtime
const CursorSchema = Schema.Struct({ x: Schema.Number, y: Schema.Number });
const cursors = client.awareness("cursors", CursorSchema);
cursors.update({ x: 120, y: 80 });
cursors.onChange(peers => console.log("live cursors:", peers));

client.close();

CRDT types

Type Use case Example key
GCounter Page views, likes gc:views
PNCounter Inventory, votes pn:stock
ORSet Shopping cart, tags or:cart
LwwRegister User profile, config lw:title
Presence Who's online, visitor count pr:room
CRDTMap Structured document with typed fields cm:doc
RGA Ordered sequence β€” collaborative text editing rg:doc
TreeCRDT Hierarchical tree β€” outlines, document trees, mind maps tr:outline

Awareness

Non-CRDT ephemeral channel for high-frequency transient state (cursors, selections, "is typing"). Updates fan out in real time but are never persisted β€” use Presence when you need durability and TTL-based cleanup, Awareness when you need raw speed.

CRDTMap lets you assign a different CRDT type to each key within a single document. Each key merges independently using its own conflict resolution semantics.

Collaborative text editing (RGA)

RGA (Replicated Growable Array) is Meridian's ordered-sequence CRDT β€” the same algorithm behind collaborative editors like Google Docs. Every character has a stable, unique identity across all peers, so concurrent insertions and deletions converge without conflict.

const doc = client.rga("rg:document");

// Insert text at position 5 (0-indexed character offset)
doc.insert(5, "Hello");

// Delete 3 characters starting at position 2
doc.delete(2, 3);

// Read the current text
const { text } = doc.value();

// React to remote changes
doc.onChange(({ text }) => editor.setValue(text));
// React
const { text, insert, delete: del } = useRga("rg:document");

Concurrent edits from multiple clients are merged automatically β€” the final text is identical on every peer regardless of arrival order.

Hierarchical trees (TreeCRDT)

TreeCRDT implements the Kleppmann et al. 2021 move-operation algorithm β€” the only known CRDT that handles concurrent move operations correctly. This makes it suitable for outlines, task hierarchies, document trees, and mind maps where nodes are frequently reorganized.

const tree = client.tree("tr:outline");

// Create nodes
const root = tree.addNode(null, { title: "Project" });
const task1 = tree.addNode(root, { title: "Research" });
const task2 = tree.addNode(root, { title: "Implementation" });

// Move a node to a different parent β€” safe under concurrent moves
tree.move(task2, task1);

// Read the current tree
const { roots } = tree.value();
// roots = [{ id, data, children: [...] }]

// React to remote changes
tree.onChange(({ roots }) => renderTree(roots));
// React
const { roots, addNode, move } = useTree("tr:outline");

Concurrent moves (e.g. two peers moving the same node to different parents at the same time) are resolved deterministically β€” no cycles, no lost nodes.

Query Engine

Aggregate data across multiple CRDTs in a single request β€” no need to read them one by one.

// Sum all page view counters matching a glob pattern
const result = await client.query({ from: "gc:views-*", aggregate: "sum" });
console.log(result.value);  // total across all matched GCounters

// Union all shopping carts
const carts = await client.query({ from: "or:cart-*", aggregate: "union" });

// Latest config value across regions
const config = await client.query({ from: "lw:config-*", aggregate: "latest" });

Or reactively in React:

const spec = useMemo(() => ({ from: "gc:views-*", aggregate: "sum" as const }), []);
const { data, loading } = useQuery(spec);

See the Query Engine docs for the full aggregation table and where clause filters.

Live Queries

Subscribe once β€” get a push every time matching CRDTs change. No polling, no manual re-fetch.

const handle = client.liveQuery({ from: "gc:views-*", aggregate: "sum" });
handle.onResult(result => console.log("live total:", result.value));

// Cancel
handle.close();

Or in React β€” useLiveQuery connects on mount, updates on every delta, and disconnects on unmount:

const spec = useMemo(() => ({ from: "gc:views-*", aggregate: "sum" as const }), []);
const { data, loading } = useLiveQuery(spec);

The SDK re-sends subscriptions automatically after a WebSocket reconnect. Set type to avoid re-executing queries for unrelated CRDT deltas:

// Only re-executes when a GCounter changes β€” skips ORSet/LwwRegister deltas
client.liveQuery({ from: "gc:views-*", type: "gcounter", aggregate: "sum" });

Edge deploy (Cloudflare Workers)

Deploy Meridian to the edge in minutes β€” no server, no Docker, no ops.

cd crates/meridian-edge
cp .dev.vars.example .dev.vars  # add your MERIDIAN_SIGNING_KEY
wrangler dev                     # local dev
wrangler deploy                  # production on Cloudflare

The edge runtime uses Durable Objects for per-namespace state (replaces sled), compiles to WASM via wasm-bindgen, and exposes the exact same WebSocket + REST API as the native server. Your SDK client connects to either without any code change:

import { Effect } from "effect";
import { MeridianClient } from "meridian-sdk";

// Native server
const client = await Effect.runPromise(
  MeridianClient.create({ url: "http://localhost:3000", namespace: "my-room", token })
);

// Edge worker (same SDK, different URL)
const client = await Effect.runPromise(
  MeridianClient.create({ url: "https://my-worker.workers.dev", namespace: "my-room", token })
);

See crates/meridian-edge/ for full setup.

Packages

npm

Package Description
meridian-sdk TypeScript SDK β€” Effect-based, msgpack, fully typed. Includes stream() on all CRDT handles and MeridianLive Layer for DI
meridian-react React hooks β€” useGCounter, usePresence, useAwareness, etc.
meridian-devtools Devtools panel β€” CRDT inspector, event stream, WAL history, op latency P50/P99
meridian-cli CLI β€” meridian inspect and meridian replay for terminal-based debugging

Rust crates

Crate Description
server Native binary β€” axum + tokio
meridian-core Shared logic β€” CRDTs, auth, protocol (no runtime dep)
meridian-edge Cloudflare Workers runtime β€” WASM, Durable Objects
meridian-storage Pluggable storage backends β€” sled, PostgreSQL, Redis, in-memory; S3 WAL archive (--features wal-archive-s3)
meridian-cluster Multinode clustering β€” Redis Pub/Sub + HTTP push transport

Configuration

Variable Default Description
MERIDIAN_BIND 0.0.0.0:3000 TCP bind address
MERIDIAN_DATA_DIR ./data sled storage path
MERIDIAN_SIGNING_KEY (random) 32-byte hex ed25519 seed
MERIDIAN_WEBHOOK_URL (unset) Webhook endpoint URL
MERIDIAN_WEBHOOK_SECRET (unset) HMAC-SHA256 signing secret
REDIS_URL (unset) Redis URL β€” enables cluster mode (--features cluster)
MERIDIAN_PEERS (unset) Comma-separated peer URLs β€” enables HTTP cluster mode (--features cluster-http)
MERIDIAN_NODE_ID (auto) Unique node ID β€” auto-derived from hostname+port if unset
MERIDIAN_ANTI_ENTROPY_SECS 30 Gossip interval in seconds
S3_BUCKET (unset) Enable S3 WAL archive β€” bucket name (--features wal-archive-s3)
S3_ENDPOINT (unset) S3-compatible endpoint override (R2, MinIO, LocalStack)
S3_REGION us-east-1 AWS region
S3_KEY_PREFIX wal/ Object key prefix for WAL segments
WAL_SEGMENT_SIZE 500 Number of WAL entries per S3 segment

About

Self-hosted real-time collaborative data store with native CRDT primitives and WebSocket live sync. πŸ¦€

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages