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 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 Peer
- type PeerConfig
- 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 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 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 Peer ¶ added in v0.19.0
type Peer struct {
// contains filtered or unexported fields
}
A Peer is one browser's side of a WebRTC connection, from before a channel exists until it is open. It wraps the native RTCPeerConnection and is used once, by one goroutine: offer or answer, then take the channel, then close.
func NewPeer ¶ added in v0.19.0
func NewPeer(cfg PeerConfig) (*Peer, error)
NewPeer creates a Peer over a fresh RTCPeerConnection. It fails only where there is no RTCPeerConnection to create — outside a browser, or in one old enough not to have it — which a page can report before it offers to connect.
func (*Peer) AcceptAnswer ¶ added in v0.19.0
AcceptAnswer takes the answer the other browser pasted back and completes the offerer's side of the exchange. Only an offerer accepts an answer; a peer that has not offered has nothing for the answer to complete.
func (*Peer) Answer ¶ added in v0.19.0
Answer plays the browser that joins: it takes the offerer's pasted offer, sets it, makes an answer, and returns the local description once every address has been gathered. Hand the returned blob back to the offerer. The data channel is not made here — it arrives on the connection, and Peer.DataChannel hands it over once it is open.
func (*Peer) Close ¶ added in v0.19.0
Close ends the connection and gives every callback back. It is safe to call more than once, and safe to call on a handshake that never finished.
func (*Peer) DataChannel ¶ added in v0.19.0
DataChannel returns the open channel, for handing to DataChannel or Server.ServeDataChannel. It waits: for the answerer, until the channel has arrived on the connection; for either side, until its readyState is "open". Call it after the exchange — after Peer.AcceptAnswer on the offerer, after Peer.Answer on the answerer — since a channel opens only once both descriptions are set.
func (*Peer) Offer ¶ added in v0.19.0
Offer plays the holder of the document: it creates the data channel, makes an offer, and returns the local description once every address has been gathered. Hand the returned blob to the other browser, then give its answer back through Peer.AcceptAnswer.
The channel is created here, before the offer, so that it is part of what the offer describes and is ordered — a document that arrived out of order would not be the document.
type PeerConfig ¶ added in v0.19.0
type PeerConfig struct {
ICEServers []string
}
PeerConfig configures a Peer. ICEServers is a list of STUN or TURN URLs, each the "urls" of one entry in the RTCPeerConnection configuration — for example "stun:stun.l.google.com:19302". Empty is a working configuration for two browsers on the same network, where no server is needed to discover an address that both can reach.
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.18.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) ServeDataChannel ¶ added in v0.18.0
ServeDataChannel runs one session over a data channel, with this browser holding the document. It returns when the session ends.
It is the counterpart of [Server.ServeWebSocket], for a page rather than a listener: there is no request to upgrade and no origin to check, because the page decided who it was talking to when it swapped connection descriptions with them.
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 DataChannel ¶ added in v0.18.0
DataChannel returns a transport over an RTCDataChannel the page has already opened. The channel must be open — its readyState "open" — because a participant that joins before the connection exists has nothing to join.
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.