5 files changed,
+237,
-3
+13,
-0
1@@ -105,6 +105,19 @@ func (o *Output) ToArray() (*Array, error) {
2 return &Array{branch: branch}, nil
3 }
4
5+// ToXmlElement converts the output to an XmlElement.
6+// Returns error if the output is not a Y.XmlElement.
7+func (o *Output) ToXmlElement() (*XmlElement, error) {
8+ if o.Tag() != TagXmlElem {
9+ return nil, fmt.Errorf("output is not a Y.XmlElement (got %v)", o.Tag())
10+ }
11+ branch := C.youtput_read_yxmlelem(o.ptr)
12+ if branch == nil {
13+ return nil, fmt.Errorf("failed to read yxmlelem from output")
14+ }
15+ return &XmlElement{branch: branch}, nil
16+}
17+
18 // IsNull returns true if the value is null.
19 func (o *Output) IsNull() bool {
20 return o.Tag() == TagJSONNull
+98,
-0
1@@ -1,6 +1,7 @@
2 package ygo_test
3
4 import (
5+ "fmt"
6 "git.kilimanjaro.io/ygo"
7 "testing"
8 )
9@@ -58,6 +59,103 @@ func TestOutputToInterface(t *testing.T) {
10 }
11 }
12
13+func TestOutputToXmlElement(t *testing.T) {
14+ doc, err := ygo.NewDoc()
15+ if err != nil {
16+ t.Fatalf("failed to create doc: %v", err)
17+ }
18+ defer doc.Destroy()
19+
20+ frag, err := doc.GetXmlFragment("test")
21+ if err != nil {
22+ t.Fatalf("failed to get fragment: %v", err)
23+ }
24+ defer frag.Destroy()
25+
26+ // Create parent with child
27+ err = doc.WithWriteTransaction(func(txn *ygo.Transaction) error {
28+ parent, err := frag.InsertElement(txn, 0, "parent")
29+ if err != nil {
30+ return err
31+ }
32+ defer parent.Destroy()
33+
34+ child, err := parent.InsertElement(txn, 0, "child")
35+ if err != nil {
36+ return err
37+ }
38+ child.Destroy()
39+
40+ return nil
41+ })
42+ if err != nil {
43+ t.Fatalf("transaction failed: %v", err)
44+ }
45+
46+ // Read back and test ToXmlElement
47+ err = doc.WithReadTransaction(func(txn *ygo.Transaction) error {
48+ // Get the parent element
49+ first, err := frag.FirstChild(txn)
50+ if err != nil {
51+ return err
52+ }
53+ if first == nil {
54+ return fmt.Errorf("expected first child")
55+ }
56+
57+ // Convert to XmlElement using ToXmlElement
58+ parent, err := first.ToXmlElement()
59+ first.Destroy()
60+ if err != nil {
61+ return fmt.Errorf("ToXmlElement failed: %w", err)
62+ }
63+ defer parent.Destroy()
64+
65+ // Verify we can use the element
66+ tag, err := parent.Tag()
67+ if err != nil {
68+ return err
69+ }
70+ if tag != "parent" {
71+ t.Errorf("expected tag 'parent', got '%s'", tag)
72+ }
73+
74+ // Get child and convert
75+ childIter, err := parent.ChildIter(txn)
76+ if err != nil {
77+ return err
78+ }
79+
80+ childOutput, err := childIter.Next()
81+ if err != nil {
82+ return err
83+ }
84+ if childOutput == nil {
85+ return fmt.Errorf("expected child")
86+ }
87+
88+ child, err := childOutput.ToXmlElement()
89+ childOutput.Destroy()
90+ if err != nil {
91+ return fmt.Errorf("child ToXmlElement failed: %w", err)
92+ }
93+ defer child.Destroy()
94+
95+ childTag, err := child.Tag()
96+ if err != nil {
97+ return err
98+ }
99+ if childTag != "child" {
100+ t.Errorf("expected child tag 'child', got '%s'", childTag)
101+ }
102+
103+ return nil
104+ })
105+ if err != nil {
106+ t.Fatalf("read transaction failed: %v", err)
107+ }
108+}
109+
110 func TestOutputEdgeCases(t *testing.T) {
111 t.Run("nil output pointer", func(t *testing.T) {
112 var out *ygo.Output
+3,
-1
1@@ -123,7 +123,9 @@ func (c *AwarenessClient) HandleUpdate(data []byte) error {
2 stateJSON := data[offset:]
3 var state map[string]interface{}
4 if err := json.Unmarshal(stateJSON, &state); err != nil {
5- return err
6+ // Non-JSON awareness state is valid (e.g., empty or binary)
7+ // Treat as empty state rather than failing
8+ state = nil
9 }
10
11 c.clients[clientID] = AwarenessState{
M
xml.go
+66,
-0
1@@ -451,6 +451,72 @@ func (f *XmlFragment) FirstChild(txn *Transaction) (*Output, error) {
2 return &Output{ptr: child}, nil
3 }
4
5+// FragmentChildIter iterates over XmlFragment children.
6+type FragmentChildIter struct {
7+ output *C.YOutput
8+ txn *Transaction
9+}
10+
11+// Destroy is a no-op - children are managed by parent.
12+func (it *FragmentChildIter) Destroy() {
13+ // No-op, children are owned by parent
14+}
15+
16+// Next returns the next child node. Returns nil when iteration is complete.
17+func (it *FragmentChildIter) Next() (*Output, error) {
18+ if it.output == nil {
19+ return nil, nil // End of iteration
20+ }
21+
22+ // Save the current output for return
23+ currentOutput := it.output
24+
25+ // Get the branch from current output to find next sibling
26+ var currentBranch *C.Branch
27+ tag := ValueTag(currentOutput.tag)
28+ switch tag {
29+ case TagXmlElem:
30+ currentBranch = C.youtput_read_yxmlelem(currentOutput)
31+ case TagXmlText:
32+ currentBranch = C.youtput_read_yxmltext(currentOutput)
33+ default:
34+ // Can't continue iteration
35+ it.output = nil
36+ return &Output{ptr: currentOutput}, nil
37+ }
38+
39+ // Get next sibling output
40+ nextOutput := C.yxml_next_sibling(currentBranch, it.txn.ptr)
41+
42+ if nextOutput == nil {
43+ it.output = nil
44+ } else {
45+ it.output = nextOutput
46+ }
47+
48+ // Return the current output
49+ return &Output{ptr: currentOutput}, nil
50+}
51+
52+// ChildIter returns an iterator over the fragment's direct children.
53+func (f *XmlFragment) ChildIter(txn *Transaction) (*FragmentChildIter, error) {
54+ if f.branch == nil {
55+ return nil, ErrNilBranch
56+ }
57+ if txn == nil || txn.ptr == nil {
58+ return nil, ErrNilTransaction
59+ }
60+
61+ // Get first child output
62+ firstOutput := C.yxmlelem_first_child(f.branch)
63+ if firstOutput == nil {
64+ return &FragmentChildIter{output: nil, txn: txn}, nil
65+ }
66+
67+ // Return iterator with first output
68+ return &FragmentChildIter{output: firstOutput, txn: txn}, nil
69+}
70+
71 // MarshalJSONWithTxn implements JSON marshaling for XmlElement.
72 // Returns JSON in the format: {"type": "tag", "attrs": {...}, "children": [...]}
73 func (e *XmlElement) MarshalJSONWithTxn(txn *Transaction) ([]byte, error) {
+57,
-2
1@@ -4,6 +4,7 @@ import (
2 "context"
3 "fmt"
4 "io"
5+ "log"
6 "net/http"
7 "net/url"
8 "strings"
9@@ -63,6 +64,7 @@ type proxyConfig struct {
10 onDisconnect OnDisconnectHook
11 docIDFunc DocIDFunc
12 masterCtx context.Context
13+ allowedOrigins []string
14 }
15
16 // OnConnectHook is called when a client connects, before websocket upgrade.
17@@ -152,6 +154,26 @@ func WithContext(ctx context.Context) ProxyOption {
18 }
19 }
20
21+// WithAllowedOrigins sets the allowed origin patterns for websocket connections.
22+// Patterns can include wildcards, e.g., "*" to allow all origins, or "*.example.com"
23+// to allow subdomains. If not specified, the websocket library's default behavior
24+// applies (same-origin only).
25+//
26+// Example:
27+//
28+// handler := ysweet.ProxyHandler("ws://upstream:8080",
29+// ysweet.WithAllowedOrigins("*"), // Allow all origins (development only)
30+// )
31+//
32+// handler := ysweet.ProxyHandler("ws://upstream:8080",
33+// ysweet.WithAllowedOrigins("*.example.com", "localhost:*"),
34+// )
35+func WithAllowedOrigins(patterns ...string) ProxyOption {
36+ return func(c *proxyConfig) {
37+ c.allowedOrigins = patterns
38+ }
39+}
40+
41 // mergeContexts returns a context that cancels when either ctx1 or ctx2 is cancelled.
42 // The returned context's Done channel is closed when either parent is cancelled.
43 func mergeContexts(ctx1, ctx2 context.Context) (context.Context, context.CancelFunc) {
44@@ -229,6 +251,13 @@ func ProxyHandler(targetURL string, opts ...ProxyOption) http.Handler {
45 func handleWebsocketProxy(w http.ResponseWriter, r *http.Request, config *proxyConfig, docID string) {
46 ctx := r.Context()
47
48+ // DEBUG: Log request details before attempting websocket upgrade
49+ log.Printf("[DEBUG] handleWebsocketProxy: docID=%s method=%s url=%s", docID, r.Method, r.URL.String())
50+ log.Printf("[DEBUG] handleWebsocketProxy: remote_addr=%s", r.RemoteAddr)
51+ log.Printf("[DEBUG] handleWebsocketProxy: origin=%s", r.Header.Get("Origin"))
52+ log.Printf("[DEBUG] handleWebsocketProxy: upgrade_header=%s", r.Header.Get("Upgrade"))
53+ log.Printf("[DEBUG] handleWebsocketProxy: connection_header=%s", r.Header.Get("Connection"))
54+
55 // Merge with master context if configured
56 if config.masterCtx != nil {
57 var cancel context.CancelFunc
58@@ -237,16 +266,24 @@ func handleWebsocketProxy(w http.ResponseWriter, r *http.Request, config *proxyC
59 }
60
61 // Accept websocket connection from client
62+ // Use configured allowed origins, or nil for same-origin default
63 wsOpts := &websocket.AcceptOptions{}
64+ if len(config.allowedOrigins) > 0 {
65+ wsOpts.OriginPatterns = config.allowedOrigins
66+ }
67 clientConn, err := websocket.Accept(w, r, wsOpts)
68 if err != nil {
69+ // DEBUG: Log why Accept failed
70+ log.Printf("[DEBUG] websocket.Accept failed: err=%v", err)
71 // Connection already rejected, nothing more to do
72 return
73 }
74+ log.Printf("[DEBUG] websocket.Accept succeeded for docID=%s", docID)
75 defer clientConn.Close(websocket.StatusNormalClosure, "")
76
77 // Build upstream URL (preserves query parameters including token)
78 upstreamURL := buildUpstreamURL(config.targetURL, docID, r.URL.Query())
79+ log.Printf("[DEBUG] connecting to upstream: url=%s", upstreamURL)
80
81 // Connect to upstream y-sweet server
82 upstreamOpts := &websocket.DialOptions{}
83@@ -259,17 +296,22 @@ func handleWebsocketProxy(w http.ResponseWriter, r *http.Request, config *proxyC
84
85 upstreamConn, _, err := websocket.Dial(ctx, upstreamURL, upstreamOpts)
86 if err != nil {
87+ // DEBUG: Log why Dial failed
88+ log.Printf("[DEBUG] websocket.Dial to upstream failed: url=%s err=%v", upstreamURL, err)
89 // Check if this was due to context cancellation
90 if ctx.Err() != nil {
91+ log.Printf("[DEBUG] dial failed due to context cancellation")
92 clientConn.Close(websocket.StatusNormalClosure, "")
93 if config.onDisconnect != nil {
94 config.onDisconnect(context.Background(), docID, DisconnectReasonContextCancelled)
95 }
96 return
97 }
98+ log.Printf("[DEBUG] closing client connection due to upstream dial failure")
99 clientConn.Close(websocket.StatusInternalError, "upstream connection failed")
100 return
101 }
102+ log.Printf("[DEBUG] websocket.Dial to upstream succeeded for docID=%s", docID)
103 defer upstreamConn.Close(websocket.StatusNormalClosure, "")
104
105 // Call OnWebsocketUpgrade hook if configured
106@@ -339,6 +381,8 @@ func buildUpstreamURL(baseURL, docID string, query url.Values) string {
107 // proxyConnections proxies messages bidirectionally between client and upstream.
108 // It runs until either connection closes or the context is cancelled.
109 func proxyConnections(ctx context.Context, clientConn, upstreamConn *websocket.Conn, config *proxyConfig, docID string) {
110+ log.Printf("[DEBUG] proxyConnections started for docID=%s", docID)
111+
112 // Create a cancelable context for this proxy session
113 ctx, cancel := context.WithCancel(ctx)
114 defer cancel()
115@@ -352,7 +396,10 @@ func proxyConnections(ctx context.Context, clientConn, upstreamConn *websocket.C
116 // Proxy client -> upstream
117 go func() {
118 defer close(done)
119- proxyMessages(ctx, clientConn, upstreamConn, "client->upstream")
120+ err := proxyMessages(ctx, clientConn, upstreamConn, "client->upstream")
121+ if err != nil {
122+ log.Printf("[DEBUG] client->upstream proxy error for docID=%s: %v", docID, err)
123+ }
124 // Check if context was cancelled when we return
125 if ctx.Err() != nil {
126 atomic.StoreInt32(&contextWasCancelled, 1)
127@@ -361,7 +408,10 @@ func proxyConnections(ctx context.Context, clientConn, upstreamConn *websocket.C
128
129 // Proxy upstream -> client
130 go func() {
131- proxyMessages(ctx, upstreamConn, clientConn, "upstream->client")
132+ err := proxyMessages(ctx, upstreamConn, clientConn, "upstream->client")
133+ if err != nil {
134+ log.Printf("[DEBUG] upstream->client proxy error for docID=%s: %v", docID, err)
135+ }
136 // Check if context was cancelled when we return
137 if ctx.Err() != nil {
138 atomic.StoreInt32(&contextWasCancelled, 1)
139@@ -370,6 +420,7 @@ func proxyConnections(ctx context.Context, clientConn, upstreamConn *websocket.C
140
141 // Wait for either direction to close
142 <-done
143+ log.Printf("[DEBUG] proxyConnections: first direction closed for docID=%s", docID)
144
145 // Check if context was cancelled (indicates shutdown)
146 if atomic.LoadInt32(&contextWasCancelled) == 1 {
147@@ -381,8 +432,10 @@ func proxyConnections(ctx context.Context, clientConn, upstreamConn *websocket.C
148 select {
149 case <-done:
150 // Other direction already closed
151+ log.Printf("[DEBUG] proxyConnections: both directions closed (context cancelled) for docID=%s", docID)
152 case <-time.After(5 * time.Second):
153 // Drain timeout exceeded, force close
154+ log.Printf("[DEBUG] proxyConnections: drain timeout exceeded for docID=%s", docID)
155 }
156 }
157
158@@ -393,6 +446,8 @@ func proxyConnections(ctx context.Context, clientConn, upstreamConn *websocket.C
159 clientConn.Close(websocket.StatusNormalClosure, "")
160 upstreamConn.Close(websocket.StatusNormalClosure, "")
161
162+ log.Printf("[DEBUG] proxyConnections ended for docID=%s, reason=%d", docID, disconnectReason)
163+
164 // Call OnDisconnect hook if configured
165 if config.onDisconnect != nil {
166 disconnectMu.Lock()