scooter  ·  2026-04-03

types.go

  1package sync
  2
  3import (
  4	"context"
  5	"fmt"
  6	"sync"
  7	"time"
  8
  9	"github.com/coder/websocket"
 10)
 11
 12// DisconnectReason indicates why the sync client disconnected.
 13type DisconnectReason int
 14
 15const (
 16	// DisconnectReasonClientInitiated indicates the context was cancelled
 17	// or the client was explicitly closed by the user.
 18	DisconnectReasonClientInitiated DisconnectReason = iota
 19
 20	// DisconnectReasonYSweetInitiated indicates the y-sweet server closed
 21	// the connection from its end.
 22	DisconnectReasonYSweetInitiated
 23
 24	// DisconnectReasonDocumentIdle indicates the client disconnected because
 25	// no other clients were connected and the idle timeout expired.
 26	DisconnectReasonDocumentIdle
 27)
 28
 29// String returns a human-readable description of the disconnect reason.
 30func (r DisconnectReason) String() string {
 31	switch r {
 32	case DisconnectReasonClientInitiated:
 33		return "ClientInitiated"
 34	case DisconnectReasonYSweetInitiated:
 35		return "YSweetInitiated"
 36	case DisconnectReasonDocumentIdle:
 37		return "DocumentIdle"
 38	default:
 39		return "Unknown"
 40	}
 41}
 42
 43// Status represents the connection state of the sync client.
 44type Status int
 45
 46const (
 47	// StatusDisconnected means no active connection to the server.
 48	StatusDisconnected Status = iota
 49	// StatusConnecting means the client is attempting to connect.
 50	StatusConnecting
 51	// StatusConnected means an active connection is established.
 52	StatusConnected
 53	// StatusDisconnecting means the client is shutting down gracefully.
 54	StatusDisconnecting
 55)
 56
 57// UpdateData holds raw binary update data from the Yjs sync protocol.
 58type UpdateData []byte
 59
 60// Transaction defines the methods a transaction must provide for sync.
 61type Transaction interface {
 62	ApplyUpdate(data []byte) error
 63	GetStateVector() []byte
 64}
 65
 66// DocInterface defines the methods a document must provide for sync.
 67// This allows the sync client to work with any implementation that matches this interface.
 68type DocInterface interface {
 69	// WithReadTransaction executes a callback within a read-only transaction.
 70	WithReadTransaction(fn func(Transaction) error) error
 71	// WithWriteTransaction executes a callback within a read-write transaction.
 72	// The transaction parameter can apply updates using raw byte data.
 73	WithWriteTransaction(fn func(Transaction) error) error
 74	// GetStateDiff returns an update containing all changes since the given state vector.
 75	GetStateDiff(stateVector []byte) []byte
 76}
 77
 78// SyncClient manages a WebSocket connection to a y-sweet server.
 79// It handles document synchronization, reconnection, and awareness tracking.
 80type SyncClient struct {
 81	endpoint     string
 82	authToken    string
 83	doc          DocInterface
 84	conn         *websocket.Conn
 85	status       Status
 86	onUpdate     func(doc DocInterface) error
 87	ackedVersion int64
 88	localVersion int64
 89	retries      int
 90	awareness    *AwarenessClient
 91	closeOnce    sync.Once
 92	done         chan struct{}
 93	disconnect   func()
 94	mu           sync.RWMutex // protects status and syncConn
 95
 96	// Internal sync connection for actual WebSocket handling
 97	syncConn *SyncConn
 98}
 99
100// Done returns a channel that is closed when the client is stopped.
101func (c *SyncClient) Done() <-chan struct{} {
102	return c.done
103}
104
105// Status returns the current connection state of the client.
106func (c *SyncClient) Status() Status {
107	c.mu.RLock()
108	defer c.mu.RUnlock()
109	return c.status
110}
111
112// NewSyncClient creates a new sync client for the document.
113// The client must be started with Start() to begin synchronization.
114func NewSyncClient(doc DocInterface, opts ...Option) *SyncClient {
115	options := defaultOptions()
116	for _, opt := range opts {
117		opt(&options)
118	}
119	disconnect := func() {}
120
121	client := &SyncClient{
122		endpoint:     options.Endpoint,
123		authToken:    options.AuthToken,
124		doc:          doc,
125		status:       StatusDisconnected,
126		onUpdate:     options.OnUpdate,
127		ackedVersion: -1,
128		localVersion: 0,
129		retries:      0,
130		done:         make(chan struct{}),
131		disconnect:   disconnect,
132	}
133
134	// Create internal SyncConn for WebSocket handling
135	syncOpts := Options{
136		Endpoint:         options.Endpoint,
137		AuthToken:        options.AuthToken,
138		OnUpdate:         options.OnUpdate,
139		AwarenessTimeout: options.AwarenessTimeout,
140		AwarenessState:   options.AwarenessState,
141		OnConnect:        options.OnConnect,
142		OnDisconnect:     options.OnDisconnect,
143	}
144	client.syncConn = NewSyncConn(doc, syncOpts)
145
146	// Default to 5 minutes if not set
147	timeout := options.AwarenessTimeout
148	if timeout <= 0 {
149		timeout = 5 * time.Minute
150	}
151
152	// Set up the disconnect function to trigger disconnect with DocumentIdle reason
153	client.disconnect = func() {
154		if client.syncConn != nil {
155			client.syncConn.SetDisconnectReason(DisconnectReasonDocumentIdle)
156			// Trigger the disconnect callback directly with DocumentIdle reason
157			// Note: onDisconnect is the field, not opts.OnDisconnect
158			client.syncConn.statsMu.RLock()
159			onDisconnect := client.syncConn.onDisconnect
160			client.syncConn.statsMu.RUnlock()
161			if onDisconnect != nil {
162				onDisconnect(DisconnectReasonDocumentIdle)
163			}
164			client.syncConn.Close()
165		}
166	}
167
168	client.awareness = NewAwarenessClient(0, timeout, client.disconnect)
169
170	// Wire up awareness updates from SyncConn to the awareness client
171	client.syncConn.SetAwarenessUpdate(func(data []byte) error {
172		return client.awareness.HandleUpdate(data)
173	})
174
175	return client
176}
177
178// Option configures a SyncClient when passed to NewSyncClient.
179type Option func(*Options)
180
181// Options holds the configuration for a SyncClient.
182type Options struct {
183	Endpoint         string
184	AuthToken        string
185	OnUpdate         func(doc DocInterface) error
186	AwarenessTimeout time.Duration
187	AwarenessState   []byte
188	OnConnect        func()                 // called when connected
189	OnDisconnect     func(DisconnectReason) // called when disconnected with reason
190}
191
192// defaultOptions returns the default configuration options.
193func defaultOptions() Options {
194	return Options{}
195}
196
197// DefaultOptions returns the default configuration options.
198// This is useful for testing and for creating a base options struct.
199func DefaultOptions() Options {
200	return Options{}
201}
202
203// WithEndpoint sets the WebSocket endpoint URL for the sync client.
204// This is the address of the y-sweet server to connect to.
205func WithEndpoint(url string) Option {
206	return func(o *Options) {
207		o.Endpoint = url
208	}
209}
210
211// WithAuthToken sets the authentication token for the sync client.
212// This token is sent as the Authorization header when connecting.
213func WithAuthToken(token string) Option {
214	return func(o *Options) {
215		o.AuthToken = token
216	}
217}
218
219// WithOnUpdate sets a callback function that is called when the document
220// receives updates from the server. The callback receives the document.
221func WithOnUpdate(fn func(doc DocInterface) error) Option {
222	return func(o *Options) {
223		o.OnUpdate = fn
224	}
225}
226
227// WithAwarenessTimeout sets the timeout for disconnecting when no other
228// clients are visible via awareness. Default is 5 minutes.
229func WithAwarenessTimeout(timeout time.Duration) Option {
230	return func(o *Options) {
231		o.AwarenessTimeout = timeout
232	}
233}
234
235// WithAwarenessState sets the initial awareness state to broadcast.
236func WithAwarenessState(state []byte) Option {
237	return func(o *Options) {
238		o.AwarenessState = state
239	}
240}
241
242// WithOnConnect sets a callback function that is called when the client
243// successfully connects to the server.
244func WithOnConnect(fn func()) Option {
245	return func(o *Options) {
246		o.OnConnect = fn
247	}
248}
249
250// WithOnDisconnect sets a callback function that is called when the client
251// disconnects from the server. The callback receives the disconnect reason.
252func WithOnDisconnect(fn func(DisconnectReason)) Option {
253	return func(o *Options) {
254		o.OnDisconnect = fn
255	}
256}
257
258// SendUpdate sends a document update to the server.
259// It returns nil if not connected, so callers don't need to check status.
260func (c *SyncClient) SendUpdate(update UpdateData) error {
261	c.mu.RLock()
262	status := c.status
263	syncConn := c.syncConn
264	c.mu.RUnlock()
265
266	if status != StatusConnected || syncConn == nil {
267		return nil
268	}
269
270	// Access the connection through syncConn
271	conn := syncConn.GetConn()
272	if conn == nil {
273		return nil
274	}
275
276	msg := encodeSyncMessage(Update, update)
277	return conn.Write(context.Background(), websocket.MessageBinary, msg)
278}
279
280// Awareness returns the awareness client for this sync client.
281// The awareness client tracks other connected clients.
282func (c *SyncClient) Awareness() *AwarenessClient {
283	return c.awareness
284}
285
286func (c *SyncClient) SetDisconnectFn(fn func()) {
287	c.disconnect = fn
288	if c.awareness != nil {
289		c.awareness = NewAwarenessClient(0, 5*time.Minute, fn)
290	}
291}
292
293// SetAuthToken sets the authentication token after construction.
294func (c *SyncClient) SetAuthToken(token string) {
295	c.authToken = token
296}
297
298// SetOnUpdate sets the callback function that is called when the document
299// receives updates from the server.
300func (c *SyncClient) SetOnUpdate(fn func(DocInterface) error) {
301	c.mu.Lock()
302	c.onUpdate = fn
303	syncConn := c.syncConn
304	c.mu.Unlock()
305
306	if syncConn != nil {
307		syncConn.SetOnUpdate(fn)
308	}
309}
310
311// Start connects to the y-sweet server and begins synchronization.
312func (c *SyncClient) Start(ctx context.Context) error {
313	c.mu.RLock()
314	syncConn := c.syncConn
315	c.mu.RUnlock()
316
317	if syncConn == nil {
318		return fmt.Errorf("sync connection not initialized")
319	}
320
321	// Start the sync connection
322	err := syncConn.Start(ctx)
323
324	c.mu.Lock()
325	if err != nil {
326		c.status = StatusDisconnected
327	} else {
328		c.status = StatusConnected
329	}
330	c.mu.Unlock()
331
332	return err
333}
334
335// Close gracefully shuts down the sync connection.
336func (c *SyncClient) Close() error {
337	c.mu.Lock()
338	syncConn := c.syncConn
339	c.status = StatusDisconnected
340	c.mu.Unlock()
341
342	if syncConn != nil {
343		return syncConn.Close()
344	}
345	return nil
346}
347
348// SendPendingAndClose sends any pending updates then closes the connection.
349// This is safe to call from within the OnUpdate callback.
350func (c *SyncClient) SendPendingAndClose() error {
351	c.mu.RLock()
352	syncConn := c.syncConn
353	c.mu.RUnlock()
354
355	if syncConn == nil {
356		return nil
357	}
358	return syncConn.SendPendingAndClose()
359}
360
361// GetStats returns current connection statistics from the underlying sync connection.
362func (c *SyncClient) GetStats() Stats {
363	c.mu.RLock()
364	syncConn := c.syncConn
365	status := c.status
366	c.mu.RUnlock()
367
368	if syncConn == nil {
369		return Stats{
370			Status: status,
371		}
372	}
373	return syncConn.GetStats()
374}
375
376// Flush performs a best-effort sync of pending updates.
377func (c *SyncClient) Flush(ctx context.Context) error {
378	c.mu.RLock()
379	syncConn := c.syncConn
380	c.mu.RUnlock()
381
382	if syncConn == nil {
383		return fmt.Errorf("not connected")
384	}
385	return syncConn.Flush(ctx)
386}
387
388// GetSyncConn returns the underlying SyncConn for advanced use.
389// This allows access to lower-level connection methods.
390func (c *SyncClient) GetSyncConn() *SyncConn {
391	c.mu.RLock()
392	defer c.mu.RUnlock()
393	return c.syncConn
394}
395
396// SetDisconnectCallback sets a callback that is called when a disconnect
397// occurs with the reason for the disconnect.
398func (c *SyncClient) SetDisconnectCallback(fn func(DisconnectReason)) {
399	c.mu.Lock()
400	defer c.mu.Unlock()
401
402	// Wrap the callback to set the reason before calling
403	c.syncConn.statsMu.Lock()
404	originalOnDisconnect := c.syncConn.onDisconnect
405	c.syncConn.onDisconnect = func(reason DisconnectReason) {
406		if fn != nil {
407			fn(reason)
408		}
409		if originalOnDisconnect != nil {
410			originalOnDisconnect(reason)
411		}
412	}
413	c.syncConn.statsMu.Unlock()
414}