@defensestation/ysync-go
v0.2.0
Published
Browser Yjs providers (Connect RPC and WebSocket) for the ysync-go collaboration backend
Maintainers
Readme
@defensestation/ysync-go
Browser Yjs providers for the ysync-go collaboration backend. Two interchangeable providers - pick per environment, mix freely: a WebSocket client and a Connect RPC client collaborate live on the same document.
npm install @defensestation/ysync-go yjsQuick start
import * as Y from "yjs";
import { WebSocketYjsProvider } from "@defensestation/ysync-go";
const ydoc = new Y.Doc();
const provider = new WebSocketYjsProvider({
ydoc,
docId: "doc-1",
baseUrl: "https://collab.example.com",
user: { name: "Ada", color: "#4338ca" },
});
// Bind ydoc to your editor (BlockNote, Tiptap, y-prosemirror, y-codemirror…)
// and provider.awareness to its cursor/presence plugin.
provider.onStatus((status) => console.log(status)); // connecting | connected | disconnected
provider.destroy(); // on unmount: announces departure, closes cleanlyOr over Connect RPC (unary + server-streaming; works everywhere fetch
streams responses):
import { ConnectYjsProvider } from "@defensestation/ysync-go";
const provider = new ConnectYjsProvider({
ydoc,
docId: "doc-1",
baseUrl: "https://collab.example.com",
user: { name: "Ada" },
});Choosing a provider
| | WebSocketYjsProvider | ConnectYjsProvider |
| --- | --- | --- |
| Wire | JSON frames over /ysync/ws | ysync.v1.YjsSyncService (Connect) |
| Edits upstream | one message per update | micro-batched unary pushes with idempotency keys |
| Best when | plain WebSockets fit your infra | you already run Connect/gRPC middleware (auth interceptors, gateways) |
Both reconnect automatically with exponential backoff (500 ms → 5 s), re-join with the document's state vector, and resync - offline edits made while disconnected are delivered on reconnect (Yjs deduplicates replays).
Common API (both providers)
| Member | Description |
| --- | --- |
| awareness | The y-protocols Awareness instance - hand it to your editor's cursor plugin. The local state's user field is prefilled from options.user. |
| clientId | Stable per-provider ID (UUID unless you pass clientId). |
| status / onStatus(fn) | "connecting" \| "connected" \| "disconnected"; returns an unsubscribe function. |
| identity / onIdentity(fn) | Server-assigned presence identity (sanitized name, palette color distinct among participants) - use it for cursor rendering instead of trusting local input. |
| destroy() | Detaches from the doc, announces departure so peers drop the cursor immediately, and closes the connection. |
Options
Shared: ydoc, docId, user: { name, color? }, baseUrl? (defaults to
window.location.origin), clientId?, reconnectInitialDelayMs?,
reconnectMaxDelayMs?, awarenessThrottleMs? (default 150 ms,
leading+trailing throttle for cursor moves).
ConnectYjsProvider only: updateFlushDelayMs? (default 100 ms) -
micro-batch window merging keystrokes into one push while a request is in
flight; retries resend the identical payload under the same idempotency
key. rpcClient? lets you supply your own Connect client (e.g. with auth
interceptors); the default client forwards user.name/user.color as
x-user-* headers, which the server treats as unauthenticated hints.
Server
This package is the client half. The server is a Go library -
github.com/defensestation/ysync-go - with
Postgres persistence, Redis multi-node fanout, and auth hooks. Clone the
repo and go run ./examples/minimal -addr :8080 for a complete local
server.
The full wire protocol (for writing clients in other languages) is documented in docs/protocol.md.
Generated protobuf descriptors are exported under @defensestation/ysync-go/ysync/v1
if you need raw RPC access.
Compaction adapter (Node 18+)
The server's update log grows until the host folds it - compaction is required in production (see the repository README). The snapshot must be built with real Yjs (Go-side merges corrupt nested-XML documents - BlockNote, ProseMirror, Tiptap), so this package ships the adapter:
import { compactDocuments } from "@defensestation/ysync-go/compaction";
// AWS Lambda handler, Cloud Run job, or any Node 18+ process:
export const handler = async () => {
const run = await compactDocuments(
{
baseUrl: "https://collab.internal.example.com",
headers: () => ({ authorization: `Bearer ${process.env.COMPACT_TOKEN}` }),
threshold: 200, // fold once a doc exceeds this many log records
keep: 50, // newest records kept individually attributed
},
await listCandidateDocIds(), // doc discovery is the host app's job
);
if (run.errors.length > 0) {
throw new Error(`${run.errors.length} document(s) failed`);
}
return run; // { compacted, skipped, results, errors }
};compactDocument(options, docId) is the single-document primitive.
Everything is idempotent - retries and concurrent runs converge - and one
bad document never aborts a compactDocuments run.
For cron-style schedulers there is a CLI:
npx ysync-compact --base-url http://localhost:8080 --docs doc-1,doc-2 \
--threshold 200 --keep 50 --header 'authorization: Bearer ...'
# or stream doc IDs, one per line:
psql -Atc 'select id from documents' | npx ysync-compact --base-url ... --docs -Compaction rewrites stored history: gate the server's Authz.CanCompact
so only this job's identity holds it.
License
MIT
