Documentation
¶
Overview ¶
Package collab carries a github.com/go-crdt/crdt document between the people editing it: a gRPC service, a server that hosts documents, and a client that joins one.
The service is thin on purpose. The document is a CRDT, so the server never transforms an operation and never decides an outcome — it applies what it is sent to its own replica and hands it to everyone else. Two consequences follow that a server-authoritative design cannot offer: a participant may edit while disconnected and reconcile later, and the server may be restarted or replaced without any client losing work.
Over what ¶
Two carriers, and which one to use is decided by where the code runs rather than by taste. WebSocket carries a session's own framing over a plain WebSocket; GRPC carries it over gRPC. One server serves both at once — Server.ServeWebSocket beside the registered service — and a participant on each edits the same document.
The reason there are two is measured. Everything a session carries is bytes some encoder in github.com/go-crdt/crdt produced and will check on arrival, so protobuf is describing fields nobody reads through it — and compiled to wasm its reflection and registry machinery cannot be linked away. The browser test client, gzipped, is 919 KB over the framing and 4 461 KB over gRPC, against 633 KB for the CRDT alone. Outside a browser none of that matters, and gRPC brings deadlines, interceptors and the tooling built around them.
The client builds for js/wasm either way, so a browser tab and a server run the same code down to the merge.
A document holds named parts ¶
What an editor holds is not one structure: the text of a file, the comments anchored into it, the record of who changed what, the messages beside it, the cells of a sheet. A document here is a github.com/go-crdt/crdt.Composite, so they travel together — one snapshot, one version, one decision about who may open it, and no instant at which the set of them disagrees.
A caller reaches for a part by name and gets a handle: Client.Text, Client.List, Client.Map. A handle edits and publishes in one step, which is why it exists rather than the replicated structure itself — a caller editing that directly would produce operations nobody ever heard, and drift away from everyone else while its own screen looked right.
Shape of a session ¶
One bidirectional stream per participant per document. The client opens with a collabpb.Join; the server answers with a collabpb.Welcome holding either the whole document or, for a participant that says what it already has, only what it missed. After that, operations and presence flow both ways until either side hangs up.
Index ¶
- Constants
- Variables
- type Client
- func (c *Client) Changes() <-chan struct{}
- func (c *Client) Close() error
- func (c *Client) Document() string
- func (c *Client) Done() <-chan struct{}
- func (c *Client) Err() error
- func (c *Client) List(name string) (*List, error)
- func (c *Client) Map(name string) (*Map, error)
- func (c *Client) Parts() []crdt.Part
- func (c *Client) Peers() []awareness.Peer
- func (c *Client) SetCursor(cursor awareness.Cursor, meta map[string]string) error
- func (c *Client) Site() crdt.SiteID
- func (c *Client) Snapshot() []byte
- func (c *Client) TakeChanges() []crdt.PartChange
- func (c *Client) Text(name string) (*Text, error)
- func (c *Client) Version() crdt.CompositeVersion
- type ClientConfig
- type Config
- type DirStore
- type GRPCServer
- type List
- func (l *List) Append(values ...[]byte) error
- func (l *List) Delete(pos, count int) error
- func (l *List) Get(pos int) ([]byte, error)
- func (l *List) Insert(pos int, values ...[]byte) error
- func (l *List) Len() int
- func (l *List) Name() string
- func (l *List) Part() crdt.Part
- func (l *List) Values() [][]byte
- type Map
- type MemoryStore
- type Server
- type Store
- type Text
- func (t *Text) Anchor(pos int) (crdt.ID, error)
- func (t *Text) AnchorUTF16(pos int) (crdt.ID, error)
- func (t *Text) AuthorRuns() []crdt.AuthorRun
- func (t *Text) AuthorRunsUTF16() []crdt.AuthorRun
- func (t *Text) Delete(pos, length int) error
- func (t *Text) DeleteUTF16(pos, length int) error
- func (t *Text) Insert(pos int, text string) error
- func (t *Text) InsertUTF16(pos int, text string) error
- func (t *Text) Len() int
- func (t *Text) LenUTF16() int
- func (t *Text) Name() string
- func (t *Text) Part() crdt.Part
- func (t *Text) Position(anchor crdt.ID) (int, bool)
- func (t *Text) PositionUTF16(anchor crdt.ID) (pos int, ok bool)
- func (t *Text) String() string
- func (t *Text) Visible(anchor crdt.ID) bool
- type Transport
- type WebSocketOption
Constants ¶
const DefaultBacklog = 256
DefaultBacklog is how many messages may be queued for one participant before the server gives up on it. See Config.
Variables ¶
var ErrClosed = errors.New("collab: session closed")
ErrClosed is why a session ended when this participant closed it, and what an edit made afterwards returns.
var ErrNoDocument = errors.New("collab: a document must have a name")
ErrNoDocument reports a document with no name. The server refuses one at the door — a join must name a document — and this refuses it too rather than let it name the directory itself, which is what the empty name encodes to.
var ErrProtocol = errors.New("collab: unexpected message")
ErrProtocol reports a message that is not part of a session: a kind that cannot arrive at that moment — a second welcome, or a join halfway through — or bytes that are not a message at all.
var ErrTransport = errors.New("collab: transport")
ErrTransport reports a carrier that could not be opened or that failed.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
A Client is one participant's view of a document: a replica that edits locally and is kept in step with everyone else.
It is safe for concurrent use. It builds for js/wasm, so a browser tab runs this code and the server's merge logic unchanged.
func Join ¶
Join opens a session over transport and returns once the document has arrived, so the client is usable the moment it is returned.
Use WebSocket unless there is a reason not to; it is what the same code compiled for a browser can afford. GRPC is there for a native peer that wants what gRPC brings with it.
The session lives until ctx is cancelled or Client.Close is called.
func (*Client) Changes ¶
func (c *Client) Changes() <-chan struct{}
Changes receives a value whenever the document or the participants changed. It coalesces: a reader that is slow sees one wake-up, not a queue of them.
func (*Client) Close ¶
Close ends the session. The local document is left intact, so its Client.Snapshot can resume later.
func (*Client) Done ¶
func (c *Client) Done() <-chan struct{}
Done is closed when the session has ended, whatever the reason.
func (*Client) Err ¶
Err returns why the session ended, or nil while it is still running. Once Client.Done is closed it is never nil: a session that was closed deliberately reports ErrClosed rather than the transport's cancellation.
func (*Client) Parts ¶ added in v0.10.0
Parts returns the parts this replica holds, in the canonical order. A part that has never been written to is not among them.
func (*Client) Peers ¶
Peers returns the other participants and where their cursors are, ordered by site.
func (*Client) SetCursor ¶
SetCursor publishes where this participant is. meta carries whatever the editor wants shown — a display name, a colour — and is not interpreted here.
Cursor positions are ephemeral and are never persisted.
func (*Client) Snapshot ¶
Snapshot returns the document in a form ClientConfig.Resume accepts, which is how a participant keeps its place across a disconnection.
func (*Client) TakeChanges ¶ added in v0.7.0
func (c *Client) TakeChanges() []crdt.PartChange
TakeChanges returns the edits made by everyone else since it was last called, in the order a view of the text has to make them, and forgets them.
It pairs with Client.Changes: that says something happened, this says what. A view that only ever applies these holds what the document holds — see crdt.Change.
Local edits are not reported. A caller that made them already knows.
func (*Client) Text ¶
Text returns a handle on the text part with this name, which is created the first time anybody writes to it. The name is arbitrary UTF-8 and is expected to carry structure — "file:src/main.tex". An empty or invalid name is refused; see crdt.Part.
func (*Client) Version ¶
func (c *Client) Version() crdt.CompositeVersion
Version returns what this participant holds, for ClientConfig.Resume or for diagnostics.
type ClientConfig ¶
type ClientConfig struct {
// Document names the document to join. It is created if it does not exist.
Document string
// Site is this participant's replica identity, and must differ from every
// other participant's in the document. See [crdt.DeriveSiteID].
Site crdt.SiteID
// Resume is a snapshot from an earlier session, obtained from
// [Client.Snapshot]. When set, the participant keeps the work it did while
// disconnected and is sent only what it missed, rather than the whole
// document.
Resume []byte
}
ClientConfig describes a participant joining a document.
type Config ¶
type Config struct {
// Store keeps documents between sessions. Defaults to a [MemoryStore].
Store Store
// Backlog is how many messages may be queued for one participant.
// A participant that falls further behind than this is disconnected with
// ResourceExhausted rather than served stale state or allowed to stall
// everyone else; it rejoins and is caught up from its version vector.
// Defaults to [DefaultBacklog].
Backlog int
// PersistEvery, when set, saves every document that has changed at this
// interval, whoever is connected. Without it a document is saved when its
// last participant leaves and when [Server.Flush] is called, so a server
// restarted while anybody was still editing loses everything since the
// document was opened.
//
// It bounds what a crash costs to this interval, which is a number an
// operator can choose. A server that sets it must be closed with
// [Server.Close], which stops the housekeeping and saves what is left.
PersistEvery time.Duration
// EvictAfter, when set, persists a document nobody has been in for this long
// and lets go of it. Without it a long-lived server holds every document it
// has ever served.
//
// A document is reloaded from the store the next time somebody joins it, so
// evicting costs a read rather than anything anybody wrote.
EvictAfter time.Duration
// OnEvictError, when set, is told about a document that could not be saved
// as it was evicted. There is nobody left to return an error to, and the
// document cannot be kept — a session may already have opened a fresh
// replica of it — so this is the only place that failure can be seen.
OnEvictError func(document string, err error)
// Clock is what [Config.EvictAfter] measures with. It defaults to time.Now,
// and exists because a caller that wants a monotonic source, or a test that
// wants to reach an hour of idleness without waiting an hour, has nowhere
// else to say so. It is read from more than one goroutine, so it must be
// safe for concurrent use and must be given here rather than set afterwards.
Clock func() time.Time
// Authorize, when set, decides whether a participant may open a document.
// It is asked once per session, after the join arrives and before the
// document is touched, so a refused session neither reads the store nor
// reveals whether the document exists.
//
// This belongs here rather than in a gRPC interceptor, which is where one
// would first look for it: an interceptor sees the method and the request
// metadata, and the document being joined is in neither — it arrives in the
// stream's first message. Anything deciding per document has to run after
// that message, which means here. Authentication, which is per connection
// rather than per document, still belongs in an interceptor; ctx carries
// whatever it put there.
//
// Returning a gRPC status error passes that status to the participant
// unchanged; any other error is reported as PermissionDenied.
Authorize func(ctx context.Context, document string, site crdt.SiteID) error
}
Config configures a Server.
type DirStore ¶ added in v0.13.0
type DirStore struct {
// contains filtered or unexported fields
}
A DirStore keeps documents as files in one directory. It is what a server wants when the documents belong to whatever else is on that disk — a project whose files are already there, backed up with them and restored with them — and it needs nothing running beside it.
A file per document, named after nothing ¶
A document name is arbitrary UTF-8 and is expected to carry structure, so the names a real consumer uses are "project:default" and "project:ods:chapter one.ods". Those are not file names: a colon is a path separator on one system this package supports, a slash is one everywhere, and "." and ".." name something else entirely. Escaping the awkward characters would leave the question of which ones, on which system, and a name that escapes to the same file as another is two documents sharing a snapshot.
So the file is named after the encoding of the name rather than the name: base64, in the alphabet made for file names, which is total and reversible and has no awkward character in it. It is unreadable at the shell, which is what DirStore.Documents is for.
What a reader may see ¶
A snapshot is written to a temporary file and renamed over the old one, so a reader sees the whole of one version or the whole of the one before. A crash during a save leaves the previous snapshot intact and a temporary file behind; the next NewDirStore on that directory clears those away.
func NewDirStore ¶ added in v0.13.0
NewDirStore returns a store keeping documents in dir, creating it if it is not there, and clears away any temporary file a previous run left behind.
func (*DirStore) Documents ¶ added in v0.13.0
Documents returns the names of the documents held, which is what a caller needs to inspect a store whose file names are an encoding rather than a name.
A file whose name is not one this store wrote is skipped rather than reported: a directory shared with anything else would otherwise turn every stray file into an error nobody can act on.
func (*DirStore) Load ¶ added in v0.13.0
Load returns the snapshot for a document, or nil if there is none yet.
func (*DirStore) Save ¶ added in v0.13.0
Save records the snapshot, replacing any previous one.
It writes a temporary file, flushes it, and renames it over the old one, so that a reader sees one whole version or the other and never half of either. On every system this package supports, a rename within a directory replaces the destination in one step.
type GRPCServer ¶ added in v0.18.0
type GRPCServer struct {
collabpb.UnimplementedCollabServer
// contains filtered or unexported fields
}
GRPCServer presents a Server as the generated gRPC service.
It exists because the Server itself no longer does. The session logic speaks the wire format in wire.go — four small types, hand-written — and that is what let it stop depending on the generated protobuf code. The reason is a measurement: compiling the server for the browser with protobuf attached takes the WebAssembly binding from 5.3 MB to 19.3, because gRPC and protobuf come with it. A browser holding a document for a colleague on another continent cannot pay that, and it is the same reason wire.go exists at all.
So gRPC is a binding rather than a foundation: this type converts, and the document logic never sees a protobuf message.
func GRPCService ¶ added in v0.18.0
func GRPCService(s *Server) *GRPCServer
GRPC presents a Server over gRPC. Register the result with collabpb.RegisterCollabServer on any grpc.Server.
func (*GRPCServer) Session ¶ added in v0.18.0
func (g *GRPCServer) Session(stream collabpb.Collab_SessionServer) error
Session is the service method: one bidirectional stream, one participant, one document.
type List ¶ added in v0.10.0
type List struct {
// contains filtered or unexported fields
}
A List is a handle on one list part — comments, a change log, the messages beside a document.
func (*List) Append ¶ added in v0.10.0
Append adds values after the last one, which is what a chat or a log does.
func (*List) Insert ¶ added in v0.10.0
Insert adds values at index pos, locally and then everywhere.
func (*List) Values ¶ added in v0.10.0
Values returns copies of every value present, in order. It is what a view of a list reads when it is told the list changed; see crdt.PartChange.
type Map ¶ added in v0.10.0
type Map struct {
// contains filtered or unexported fields
}
A Map is a handle on one map part, such as the cells of a sheet.
func (*Map) Delete ¶ added in v0.10.0
Delete removes key, locally and then everywhere. It writes a tombstone whether or not this replica holds the key; see crdt.Map.
func (*Map) Get ¶ added in v0.10.0
Get returns a copy of the value at key, and whether the key is present. It is what a view reads for each key a crdt.PartChange names.
type MemoryStore ¶
type MemoryStore struct {
// contains filtered or unexported fields
}
MemoryStore keeps documents in memory. It is the default, it is what the tests use, and it is enough for a single process that does not need to survive a restart. Anything else — Postgres, object storage — implements Store.
func (*MemoryStore) Documents ¶
func (s *MemoryStore) Documents() []string
Documents returns the names of the documents held, which is what a caller needs to inspect or migrate a store.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
A Server hosts documents. Register it with collabpb.RegisterCollabServer on any grpc.Server — over github.com/grpc-transports/websocket for browsers, over plain TCP for anything else.
Documents stay in memory once opened, so a long-lived server holds every document it has served. Call Server.Flush to persist them.
func (*Server) Close ¶ added in v0.13.0
Close stops the housekeeping Config.PersistEvery and Config.EvictAfter ask for, and saves everything that has changed. It does not end the sessions in progress: those belong to whatever is serving them, and stopping that is the caller's to do first.
Calling it twice is harmless. A server that asked for neither still has one, so a caller need not know which kind it configured.
func (*Server) Flush ¶
Flush persists every document that has changed since it was last written. A server that wants durability without waiting for participants to leave calls this on a timer, or before shutting down.
func (*Server) Follow ¶ added in v0.17.0
Follow makes this server a participant in another server's copy of a document, so that the two converge.
What it is for ¶
Not capacity. One server holds a document for a thousand participants at about three kilobytes and two and a half microseconds each, flat, which is twelve percent of a core for a document five people are typing in — see BenchmarkFanOut. A second server earns its place for two other reasons: a participant far from the first pays the round trip on every keystroke echo, and a site that goes down takes its documents with it until somebody brings them back.
Both are answered by a replica near each participant rather than by splitting a document across servers, which is what this is. It is also what the CRDT is for: two replicas that have seen the same operations hold the same document, in any order, with no agreement about the order and nothing to coordinate on the write path. There is no leader here and no consensus, which is why it works between datacentres without paying a round trip per edit.
What a link is ¶
A participant. The server being followed cannot tell the difference and does not need to: a link joins its document, is sent what it is missing, and is broadcast to like anybody else. Everything the local document learns is sent out, and everything that arrives is applied and broadcast onwards — to everyone except the link it arrived on, which is the loop prevention the subscriber machinery already had.
That prevention is not enough on its own, and the missing half is in applyOperations: two servers that follow each other would otherwise pass an operation back and forth forever, each applying it harmlessly and telling the other again. Operations that do not advance the version are not passed on.
Per document ¶
A link follows one document. The alternative — a link that mirrors a whole store — is simpler to operate and replicates documents nobody is looking at, which between continents is bandwidth spent on nothing. Idle documents are evicted here already, and a link is what keeps one alive, so the set of documents a server replicates is the set somebody is using.
What this does not do ¶
It does not reconnect. A link that drops stays dropped, and the error is returned to whoever called Follow, because the policy for coming back — immediately, with a backoff, never — belongs to the operator and not to a library. It does not discover peers. It does not replicate presence: cursors are ephemeral and a link that carried them would have to decide what a cursor in another datacentre means when the link is a second behind.
func (*Server) ServeWebSocket ¶ added in v0.11.0
ServeWebSocket returns an http.Handler that runs sessions over WebSockets — the carrier a browser can afford, and the one WebSocket dials.
Mount it where the browser will reach it. Everything else is the same server: the same documents, the same store, the same Config.Authorize, and a participant here edits the same document as one arriving over gRPC.
origins, when not empty, are the Origin header values allowed to open a session, which is the check that stops another site's page from opening one with the visitor's cookies. An empty list allows only same-origin requests.
type Store ¶
type Store interface {
// Load returns the snapshot for a document, or nil if there is none yet.
// Returning nil is how a store says "new document", and is not an error.
Load(ctx context.Context, document string) ([]byte, error)
// Save records the current snapshot, replacing any previous one.
Save(ctx context.Context, document string, snapshot []byte) error
}
A Store keeps documents between sessions. It holds snapshots, which are self-contained: a document restored from one can still serve a participant that has been away, because the snapshot carries the whole history.
Implementations must be safe for concurrent use.
type Text ¶ added in v0.10.0
type Text struct {
// contains filtered or unexported fields
}
A Text is a handle on one text part: the buffer of a file, and what an editor binds to.
func (*Text) Anchor ¶ added in v0.10.0
Anchor returns the identity of the character at rune offset pos, which keeps naming that character however the text moves around it. It is what a comment or a stored selection should hold; see crdt.Doc.Anchor.
func (*Text) AnchorUTF16 ¶ added in v0.12.0
AnchorUTF16 is Text.Anchor with pos counted in UTF-16 code units, the units a page counts in. An offset falling between the two units of one character is refused rather than rounded; see crdt.ErrSurrogateBoundary.
func (*Text) AuthorRuns ¶ added in v0.10.0
AuthorRuns splits the visible text into stretches by who wrote them, which is what colouring a document by author needs.
func (*Text) AuthorRunsUTF16 ¶ added in v0.12.0
AuthorRunsUTF16 is Text.AuthorRuns with every offset and length counted in UTF-16 code units, so that a page can colour the string it holds without converting anything by hand.
func (*Text) Delete ¶ added in v0.10.0
Delete removes length runes at rune offset pos, locally and then everywhere.
func (*Text) DeleteUTF16 ¶ added in v0.10.0
DeleteUTF16 removes length code units at an offset counted in the same units.
func (*Text) Insert ¶ added in v0.10.0
Insert adds text at rune offset pos, locally and then everywhere.
func (*Text) InsertUTF16 ¶ added in v0.10.0
InsertUTF16 adds text at an offset counted in UTF-16 code units.
func (*Text) LenUTF16 ¶ added in v0.10.0
LenUTF16 returns the length a browser would report, counting UTF-16 code units. Its companions InsertUTF16 and DeleteUTF16 take offsets in the same units, so a caller in the browser never converts by hand; see crdt.Doc.
func (*Text) Part ¶ added in v0.10.0
Part names this handle's part, which is what a crdt.PartChange from Client.TakeChanges carries.
func (*Text) Position ¶ added in v0.10.0
Position returns where the character an anchor names sits now — or where it was, if it has been deleted. See crdt.Doc.Position.
func (*Text) PositionUTF16 ¶ added in v0.12.0
PositionUTF16 is Text.Position with the offset reported in UTF-16 code units. ok is false for an anchor this replica has never seen, exactly as it is there — which is not the same question as whether the character is still in the text; that one is Text.Visible.
type Transport ¶ added in v0.11.0
type Transport interface {
// contains filtered or unexported methods
}
A Transport is how a participant reaches a server. WebSocket works anywhere, a browser included; GRPC works outside one.
There are two because of what they cost where they run. Outside a browser a carrier costs nothing anybody notices, and gRPC brings deadlines, interceptors and everything already built around them. Inside one it is paid for on every load: protobuf alone is six times the size of the whole CRDT compiled to wasm — see wire.go for the measurements — so the browser gets a framing of four message kinds over a plain WebSocket instead.
Both carry the same session, byte for byte in the fields that matter, because every field in these messages is something github.com/go-crdt/crdt encoded and will check on arrival. A participant on one and a participant on the other can edit the same document.
func GRPC ¶ added in v0.11.0
func GRPC(conn grpc.ClientConnInterface) Transport
GRPC returns a transport that opens sessions on a gRPC connection.
It is deliberately not the default. Everything it carries is bytes some encoder in github.com/go-crdt/crdt produced, so protobuf is describing fields nobody reads through it — and compiled for a browser it costs six times the CRDT itself. Outside a browser that does not matter, and gRPC brings deadlines, interceptors and the tooling built around them, which is reason enough to keep it. See Transport and WebSocket.
func WebSocket ¶ added in v0.11.0
func WebSocket(url string, opts ...WebSocketOption) Transport
WebSocket returns a transport that opens sessions at url, which is "ws://" or "wss://" and the path the server's handler is mounted at.
This is the transport a browser uses, and the one to reach for by default: it is what the same code compiled to wasm can afford. See Transport.
type WebSocketOption ¶ added in v0.11.0
type WebSocketOption func(*wsTransport)
A WebSocketOption configures WebSocket.
func WithHTTPHeader ¶ added in v0.11.0
func WithHTTPHeader(h http.Header) WebSocketOption
WithHTTPHeader sends these headers with the opening handshake, which is where a cookie or a bearer token goes when the participant is not a browser.
It does not exist for a browser, because a page cannot put a header on a WebSocket handshake — and does not need to, since the browser sends the cookies for that origin itself. Code meant to run in both places should let the cookie do the work; see Config.Authorize.