8 files changed,
+419,
-30
+4,
-0
1@@ -10,6 +10,10 @@ Reference code for Yjs and the y-sweet server can be found under `ref/`. If the
2
3 You should also git clone the source for the Yjs library and inspect its Javascript source when working on compatibility issues with the JS version of the library. It is located at `https://github.com/yjs/yjs` and should be cloned to `ref/yjs` if it does not already exist.
4
5+# Breaking Changes
6+
7+This is a pre-release library in active development. There are no tagged releases so no changes are considered breaking. Modify the API as necessary without considering deprecation of existing APIs.
8+
9 # Source Control
10
11 Do not use any git or jj commands. Source control is handled separately by a human.
M
sync.go
+41,
-0
1@@ -83,6 +83,7 @@ type syncConfig struct {
2 onUpdate Hook
3 disconnectAfter time.Duration
4 awarenessState *AwarenessState
5+ onDisconnect DisconnectHookCtx
6 }
7
8 // SyncOption configures the sync connection.
9@@ -127,6 +128,23 @@ func WithAwarenessState(state *AwarenessState) SyncOption {
10 }
11 }
12
13+// WithOnDisconnect sets a hook that is called when the sync client
14+// disconnects, with the reason for the disconnection.
15+func WithOnDisconnect(fn DisconnectHook) SyncOption {
16+ return func(c *syncConfig) {
17+ c.onDisconnect = func(ctx context.Context, doc *Doc, stats SyncStats, reason DisconnectReason) error {
18+ return fn(doc, stats, reason)
19+ }
20+ }
21+}
22+
23+// WithOnDisconnectCtx sets a context-aware hook for disconnect events.
24+func WithOnDisconnectCtx(fn DisconnectHookCtx) SyncOption {
25+ return func(c *syncConfig) {
26+ c.onDisconnect = fn
27+ }
28+}
29+
30 // Sync establishes a sync connection to the y-sweet server.
31 // The document will be synchronized with other clients.
32 // When the context is cancelled or times out, the connection closes gracefully.
33@@ -177,6 +195,29 @@ func (d *Doc) Sync(ctx context.Context, opts ...SyncOption) error {
34 })
35 }
36
37+ // Set up disconnect callback if provided
38+ if cfg.onDisconnect != nil {
39+ syncClient.SetDisconnectCallback(func(reason sync.DisconnectReason) {
40+ stats := syncClient.GetStats()
41+ var publicReason DisconnectReason
42+ switch reason {
43+ case sync.DisconnectReasonClientInitiated:
44+ publicReason = DisconnectReasonClientInitiated
45+ case sync.DisconnectReasonYSweetInitiated:
46+ publicReason = DisconnectReasonYSweetInitiated
47+ case sync.DisconnectReasonDocumentIdle:
48+ publicReason = DisconnectReasonDocumentIdle
49+ }
50+ publicStats := SyncStats{
51+ PeerCount: stats.PeerCount,
52+ LastUpdate: stats.LastUpdate,
53+ PendingUpdate: stats.PendingUpdate,
54+ Status: syncStatusFromInternal(stats.Status),
55+ }
56+ cfg.onDisconnect(context.Background(), doc, publicStats, publicReason)
57+ })
58+ }
59+
60 d.sync = syncClient
61
62 go func() {
+32,
-14
1@@ -41,7 +41,10 @@ type SyncConn struct {
2
3 // Event callbacks
4 onConnect func()
5- onDisconnect func()
6+ onDisconnect func(DisconnectReason)
7+
8+ // Disconnect reason tracking
9+ disconnectReason DisconnectReason
10 }
11
12 // Stats holds connection statistics for SyncConn
13@@ -66,17 +69,18 @@ func NewSyncConn(doc DocInterface, opts Options) *SyncConn {
14 onUpdate = opts.OnUpdate
15 }
16 return &SyncConn{
17- doc: doc,
18- opts: opts,
19- onUpdate: onUpdate,
20- onConnect: opts.OnConnect,
21- onDisconnect: opts.OnDisconnect,
22- status: StatusDisconnected,
23- done: make(chan struct{}),
24- retries: 0,
25- lastUpdate: time.Time{},
26- peerCount: 0,
27- pendingUpdate: false,
28+ doc: doc,
29+ opts: opts,
30+ onUpdate: onUpdate,
31+ onConnect: opts.OnConnect,
32+ onDisconnect: opts.OnDisconnect,
33+ status: StatusDisconnected,
34+ done: make(chan struct{}),
35+ retries: 0,
36+ lastUpdate: time.Time{},
37+ peerCount: 0,
38+ pendingUpdate: false,
39+ disconnectReason: DisconnectReasonClientInitiated,
40 }
41 }
42
43@@ -285,6 +289,10 @@ func (s *SyncConn) readLoop(ctx context.Context) {
44 for {
45 _, r, err := s.conn.Reader(ctx)
46 if err != nil {
47+ // Check if this was a server-initiated disconnect (no context cancellation)
48+ if ctx.Err() == nil {
49+ s.disconnectReason = DisconnectReasonYSweetInitiated
50+ }
51 s.handleDisconnect(ctx)
52 return
53 }
54@@ -309,9 +317,10 @@ func (s *SyncConn) handleDisconnect(ctx context.Context) {
55 return
56 }
57
58- // Trigger OnDisconnect callback before marking disconnected
59+ // Get current reason and trigger OnDisconnect callback
60+ reason := s.disconnectReason
61 if s.onDisconnect != nil {
62- s.onDisconnect()
63+ s.onDisconnect(reason)
64 }
65
66 s.status = StatusDisconnected
67@@ -320,6 +329,9 @@ func (s *SyncConn) handleDisconnect(ctx context.Context) {
68 s.conn = nil
69 }
70
71+ // Reset disconnect reason for next connection (default to YSweetInitiated for reconnects)
72+ s.disconnectReason = DisconnectReasonYSweetInitiated
73+
74 backoff := initialBackoff
75 for {
76 select {
77@@ -508,6 +520,12 @@ func (s *SyncConn) Flush(ctx context.Context) error {
78 return nil
79 }
80
81+// SetDisconnectReason sets the reason for the next disconnect.
82+// This is used by the awareness client to indicate DocumentIdle.
83+func (s *SyncConn) SetDisconnectReason(reason DisconnectReason) {
84+ s.disconnectReason = reason
85+}
86+
87 // parseAwarenessPeerCount extracts the number of peers from an awareness message.
88 // Awareness messages contain alternating client IDs and their state objects.
89 // We count unique client IDs to get the peer count.
+71,
-6
1@@ -9,6 +9,37 @@ import (
2 "github.com/coder/websocket"
3 )
4
5+// DisconnectReason indicates why the sync client disconnected.
6+type DisconnectReason int
7+
8+const (
9+ // DisconnectReasonClientInitiated indicates the context was cancelled
10+ // or the client was explicitly closed by the user.
11+ DisconnectReasonClientInitiated DisconnectReason = iota
12+
13+ // DisconnectReasonYSweetInitiated indicates the y-sweet server closed
14+ // the connection from its end.
15+ DisconnectReasonYSweetInitiated
16+
17+ // DisconnectReasonDocumentIdle indicates the client disconnected because
18+ // no other clients were connected and the idle timeout expired.
19+ DisconnectReasonDocumentIdle
20+)
21+
22+// String returns a human-readable description of the disconnect reason.
23+func (r DisconnectReason) String() string {
24+ switch r {
25+ case DisconnectReasonClientInitiated:
26+ return "ClientInitiated"
27+ case DisconnectReasonYSweetInitiated:
28+ return "YSweetInitiated"
29+ case DisconnectReasonDocumentIdle:
30+ return "DocumentIdle"
31+ default:
32+ return "Unknown"
33+ }
34+}
35+
36 // Status represents the connection state of the sync client.
37 type Status int
38
39@@ -104,6 +135,8 @@ func NewSyncClient(doc DocInterface, opts ...Option) *SyncClient {
40 OnUpdate: options.OnUpdate,
41 AwarenessTimeout: options.AwarenessTimeout,
42 AwarenessState: options.AwarenessState,
43+ OnConnect: options.OnConnect,
44+ OnDisconnect: options.OnDisconnect,
45 }
46 client.syncConn = NewSyncConn(doc, syncOpts)
47
48@@ -112,8 +145,25 @@ func NewSyncClient(doc DocInterface, opts ...Option) *SyncClient {
49 if timeout <= 0 {
50 timeout = 5 * time.Minute
51 }
52- client.awareness = NewAwarenessClient(0, timeout, func() {
53- client.disconnect()
54+
55+ // Set up the disconnect function to trigger disconnect with DocumentIdle reason
56+ client.disconnect = func() {
57+ if client.syncConn != nil {
58+ client.syncConn.SetDisconnectReason(DisconnectReasonDocumentIdle)
59+ // Trigger the disconnect callback directly with DocumentIdle reason
60+ // Note: onDisconnect is the field, not opts.OnDisconnect
61+ if client.syncConn.onDisconnect != nil {
62+ client.syncConn.onDisconnect(DisconnectReasonDocumentIdle)
63+ }
64+ client.syncConn.Close()
65+ }
66+ }
67+
68+ client.awareness = NewAwarenessClient(0, timeout, client.disconnect)
69+
70+ // Wire up awareness updates from SyncConn to the awareness client
71+ client.syncConn.SetAwarenessUpdate(func(data []byte) error {
72+ return client.awareness.HandleUpdate(data)
73 })
74
75 return client
76@@ -129,8 +179,8 @@ type Options struct {
77 OnUpdate func(doc DocInterface) error
78 AwarenessTimeout time.Duration
79 AwarenessState []byte
80- OnConnect func() // NEW: called when connected
81- OnDisconnect func() // NEW: called when disconnected
82+ OnConnect func() // called when connected
83+ OnDisconnect func(DisconnectReason) // called when disconnected with reason
84 }
85
86 // defaultOptions returns the default configuration options.
87@@ -192,8 +242,8 @@ func WithOnConnect(fn func()) Option {
88 }
89
90 // WithOnDisconnect sets a callback function that is called when the client
91-// disconnects from the server.
92-func WithOnDisconnect(fn func()) Option {
93+// disconnects from the server. The callback receives the disconnect reason.
94+func WithOnDisconnect(fn func(DisconnectReason)) Option {
95 return func(o *Options) {
96 o.OnDisconnect = fn
97 }
98@@ -301,3 +351,18 @@ func (c *SyncClient) Flush(ctx context.Context) error {
99 func (c *SyncClient) GetSyncConn() *SyncConn {
100 return c.syncConn
101 }
102+
103+// SetDisconnectCallback sets a callback that is called when a disconnect
104+// occurs with the reason for the disconnect.
105+func (c *SyncClient) SetDisconnectCallback(fn func(DisconnectReason)) {
106+ // Wrap the callback to set the reason before calling
107+ originalOnDisconnect := c.syncConn.onDisconnect
108+ c.syncConn.onDisconnect = func(reason DisconnectReason) {
109+ if fn != nil {
110+ fn(reason)
111+ }
112+ if originalOnDisconnect != nil {
113+ originalOnDisconnect(reason)
114+ }
115+ }
116+}
+28,
-8
1@@ -43,7 +43,7 @@ type SyncClient struct {
2 inner *ygosync.SyncClient
3 config *syncConfig
4 onConnect SyncHookCtx
5- onDisconnect SyncHookCtx
6+ onDisconnect DisconnectHookCtx
7 onUpdate SyncHookCtx
8 statsMu sync.RWMutex
9 cachedStats SyncStats
10@@ -137,6 +137,26 @@ func (c *SyncClient) Connect(ctx context.Context) error {
11 })
12 }
13
14+ // Set up disconnect callback with reason
15+ disconnectCalled := false
16+ c.inner.SetDisconnectCallback(func(reason ygosync.DisconnectReason) {
17+ if !disconnectCalled && c.onDisconnect != nil {
18+ disconnectCalled = true
19+ stats := c.getStatsUnsafe()
20+ // Convert internal reason to public reason
21+ var publicReason DisconnectReason
22+ switch reason {
23+ case ygosync.DisconnectReasonClientInitiated:
24+ publicReason = DisconnectReasonClientInitiated
25+ case ygosync.DisconnectReasonYSweetInitiated:
26+ publicReason = DisconnectReasonYSweetInitiated
27+ case ygosync.DisconnectReasonDocumentIdle:
28+ publicReason = DisconnectReasonDocumentIdle
29+ }
30+ c.onDisconnect(context.Background(), c.doc, stats, publicReason)
31+ }
32+ })
33+
34 // Store reference in doc for awareness tracking
35 c.doc.sync = c.inner
36
37@@ -182,7 +202,7 @@ func (c *SyncClient) Close() error {
38 if c.onDisconnect != nil {
39 stats := c.getStatsUnsafe()
40 go func() {
41- c.onDisconnect(context.Background(), c.doc, stats)
42+ c.onDisconnect(context.Background(), c.doc, stats, DisconnectReasonClientInitiated)
43 }()
44 }
45
46@@ -270,17 +290,17 @@ func (c *SyncClient) OnConnectCtx(fn SyncHookCtx) {
47 }
48
49 // OnDisconnect sets a callback that is invoked when the client disconnects.
50-// The callback receives the document and current stats at disconnect time.
51+// The callback receives the document, stats, and the reason for disconnection.
52 // Note: This does NOT mean you should destroy the document - reconnect is possible.
53-func (c *SyncClient) OnDisconnect(fn SyncHook) {
54- // Wrap the non-context SyncHook to call as SyncHookCtx with background context
55- c.OnDisconnectCtx(func(ctx context.Context, doc *Doc, stats SyncStats) error {
56- return fn(doc, stats)
57+func (c *SyncClient) OnDisconnect(fn DisconnectHook) {
58+ // Wrap the non-context hook to call as DisconnectHookCtx
59+ c.OnDisconnectCtx(func(ctx context.Context, doc *Doc, stats SyncStats, reason DisconnectReason) error {
60+ return fn(doc, stats, reason)
61 })
62 }
63
64 // OnDisconnectCtx sets a context-aware callback for disconnect events.
65-func (c *SyncClient) OnDisconnectCtx(fn SyncHookCtx) {
66+func (c *SyncClient) OnDisconnectCtx(fn DisconnectHookCtx) {
67 c.statsMu.Lock()
68 defer c.statsMu.Unlock()
69 c.onDisconnect = fn
+40,
-0
1@@ -0,0 +1,40 @@
2+package ygo
3+
4+import "context"
5+
6+// DisconnectReason indicates why the sync client disconnected.
7+type DisconnectReason int
8+
9+const (
10+ // DisconnectReasonClientInitiated indicates the context was cancelled
11+ // or the client was explicitly closed by the user.
12+ DisconnectReasonClientInitiated DisconnectReason = iota
13+
14+ // DisconnectReasonYSweetInitiated indicates the y-sweet server closed
15+ // the connection from its end.
16+ DisconnectReasonYSweetInitiated
17+
18+ // DisconnectReasonDocumentIdle indicates the client disconnected because
19+ // no other clients were connected and the idle timeout expired.
20+ DisconnectReasonDocumentIdle
21+)
22+
23+// String returns a human-readable description of the disconnect reason.
24+func (r DisconnectReason) String() string {
25+ switch r {
26+ case DisconnectReasonClientInitiated:
27+ return "ClientInitiated"
28+ case DisconnectReasonYSweetInitiated:
29+ return "YSweetInitiated"
30+ case DisconnectReasonDocumentIdle:
31+ return "DocumentIdle"
32+ default:
33+ return "Unknown"
34+ }
35+}
36+
37+// DisconnectHook is the callback signature for disconnect events with reason.
38+type DisconnectHook func(doc *Doc, stats SyncStats, reason DisconnectReason) error
39+
40+// DisconnectHookCtx is the context-aware callback signature for disconnect events.
41+type DisconnectHookCtx func(ctx context.Context, doc *Doc, stats SyncStats, reason DisconnectReason) error
+173,
-0
1@@ -0,0 +1,173 @@
2+package ygo_test
3+
4+import (
5+ "context"
6+ "os"
7+ "os/exec"
8+ "syscall"
9+ "testing"
10+ "time"
11+
12+ "github.com/BTBurke/ygo"
13+ "github.com/BTBurke/ygo/ysweet"
14+)
15+
16+func skipIfNoIntegrationTests(t *testing.T) {
17+ if os.Getenv("RUN_INTEGRATION_TESTS") == "" {
18+ t.Skip("Skipping integration test: set RUN_INTEGRATION_TESTS=1 to run")
19+ }
20+}
21+
22+func TestSyncClientDisconnectReasonYSweetInitiated(t *testing.T) {
23+ skipIfNoIntegrationTests(t)
24+
25+ // Start y-sweet server
26+ ctx, cancel := context.WithCancel(context.Background())
27+ defer cancel()
28+
29+ cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve")
30+ cmd.SysProcAttr = &syscall.SysProcAttr{
31+ Setpgid: true,
32+ }
33+
34+ if err := cmd.Start(); err != nil {
35+ t.Fatalf("failed to start y-sweet server: %v", err)
36+ }
37+
38+ // Give server time to start
39+ time.Sleep(2 * time.Second)
40+
41+ // Create client and connect
42+ client, err := ysweet.NewClient("http://127.0.0.1:8080")
43+ if err != nil {
44+ t.Fatalf("failed to create y-sweet client: %v", err)
45+ }
46+
47+ docID, err := client.NewDoc("")
48+ if err != nil {
49+ t.Fatalf("failed to create document: %v", err)
50+ }
51+
52+ auth, err := client.AuthDoc(docID)
53+ if err != nil {
54+ t.Fatalf("failed to auth document: %v", err)
55+ }
56+
57+ doc, _ := ygo.NewDoc()
58+ defer doc.Destroy()
59+
60+ syncClient, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint(auth.WebsocketURL()))
61+
62+ connectCtx, connectCancel := context.WithTimeout(context.Background(), 5*time.Second)
63+ defer connectCancel()
64+
65+ err = syncClient.Connect(connectCtx)
66+ if err != nil {
67+ t.Fatalf("failed to connect: %v", err)
68+ }
69+
70+ reasonReceived := make(chan ygo.DisconnectReason, 1)
71+
72+ syncClient.OnDisconnect(func(d *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
73+ reasonReceived <- reason
74+ return nil
75+ })
76+
77+ // Shutdown server by sending SIGINT
78+ if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil {
79+ t.Fatalf("failed to kill server: %v", err)
80+ }
81+
82+ // Wait for disconnect with timeout
83+ var disconnectReason ygo.DisconnectReason
84+ select {
85+ case disconnectReason = <-reasonReceived:
86+ case <-time.After(5 * time.Second):
87+ t.Fatal("timeout waiting for disconnect callback")
88+ }
89+
90+ if disconnectReason != ygo.DisconnectReasonYSweetInitiated {
91+ t.Errorf("expected reason YSweetInitiated, got %v (%s)", disconnectReason, disconnectReason.String())
92+ }
93+
94+ syncClient.Close()
95+}
96+
97+func TestSyncClientDisconnectReasonDocumentIdle(t *testing.T) {
98+ skipIfNoIntegrationTests(t)
99+
100+ // Start y-sweet server
101+ ctx, cancel := context.WithCancel(context.Background())
102+ defer cancel()
103+
104+ cmd := exec.CommandContext(ctx, "pnpx", "y-sweet@latest", "serve")
105+ cmd.SysProcAttr = &syscall.SysProcAttr{
106+ Setpgid: true,
107+ }
108+
109+ if err := cmd.Start(); err != nil {
110+ t.Fatalf("failed to start y-sweet server: %v", err)
111+ }
112+
113+ // Give server time to start
114+ time.Sleep(2 * time.Second)
115+
116+ // Create client and connect
117+ client, err := ysweet.NewClient("http://127.0.0.1:8080")
118+ if err != nil {
119+ t.Fatalf("failed to create y-sweet client: %v", err)
120+ }
121+
122+ docID, err := client.NewDoc("")
123+ if err != nil {
124+ t.Fatalf("failed to create document: %v", err)
125+ }
126+
127+ auth, err := client.AuthDoc(docID)
128+ if err != nil {
129+ t.Fatalf("failed to auth document: %v", err)
130+ }
131+
132+ doc, _ := ygo.NewDoc()
133+ defer doc.Destroy()
134+
135+ // Set a short idle timeout
136+ syncClient, _ := ygo.NewSyncClient(doc,
137+ ygo.WithSyncEndpoint(auth.WebsocketURL()),
138+ ygo.WithDisconnectOnNoClientsAfter(2*time.Second),
139+ )
140+
141+ connectCtx, connectCancel := context.WithTimeout(context.Background(), 5*time.Second)
142+ defer connectCancel()
143+
144+ err = syncClient.Connect(connectCtx)
145+ if err != nil {
146+ t.Fatalf("failed to connect: %v", err)
147+ }
148+
149+ reasonReceived := make(chan ygo.DisconnectReason, 1)
150+
151+ syncClient.OnDisconnect(func(d *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
152+ reasonReceived <- reason
153+ return nil
154+ })
155+
156+ // Wait for idle timeout disconnect
157+ var disconnectReason ygo.DisconnectReason
158+ select {
159+ case disconnectReason = <-reasonReceived:
160+ case <-time.After(10 * time.Second):
161+ t.Fatal("timeout waiting for disconnect callback")
162+ }
163+
164+ if disconnectReason != ygo.DisconnectReasonDocumentIdle {
165+ t.Errorf("expected reason DocumentIdle, got %v (%s)", disconnectReason, disconnectReason.String())
166+ }
167+
168+ syncClient.Close()
169+
170+ // Cleanup server
171+ if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGINT); err != nil {
172+ t.Logf("warning: failed to kill server: %v", err)
173+ }
174+}
+30,
-2
1@@ -52,7 +52,7 @@ func TestSyncClientEventHooks(t *testing.T) {
2 return nil
3 })
4
5- client.OnDisconnect(func(doc *ygo.Doc, stats ygo.SyncStats) error {
6+ client.OnDisconnect(func(doc *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
7 disconnectCalled = true
8 return nil
9 })
10@@ -90,7 +90,7 @@ func TestSyncClientEventCtxHooks(t *testing.T) {
11 return nil
12 })
13
14- client.OnDisconnectCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats) error {
15+ client.OnDisconnectCtx(func(ctx context.Context, doc *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
16 disconnectCtxCalled = true
17 return nil
18 })
19@@ -163,3 +163,31 @@ func TestSyncStatsStruct(t *testing.T) {
20 t.Errorf("expected Status Connected, got %v", stats.Status)
21 }
22 }
23+
24+func TestSyncClientDisconnectReasonClientInitiated(t *testing.T) {
25+ doc, _ := ygo.NewDoc()
26+ defer doc.Destroy()
27+
28+ client, _ := ygo.NewSyncClient(doc, ygo.WithSyncEndpoint("ws://test"))
29+
30+ var reasonReceived bool
31+
32+ client.OnDisconnect(func(d *ygo.Doc, stats ygo.SyncStats, reason ygo.DisconnectReason) error {
33+ _ = reason // silence unused warning
34+ reasonReceived = true
35+ return nil
36+ })
37+
38+ // Close without connecting - callback should not be called since we were never connected
39+ client.Close()
40+
41+ // Wait a bit to ensure callback is not invoked
42+ time.Sleep(100 * time.Millisecond)
43+
44+ if reasonReceived {
45+ t.Error("disconnect callback should not be called when client was never connected")
46+ }
47+
48+ // Note: Full ClientInitiated disconnect testing requires integration tests
49+ // with a real server connection that is then closed.
50+}