add XmlElement methods
feat: add ToXmlElement() method to Output type
5 files changed,  +237, -3
M output.go
+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
M output_test.go
+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
M sync/awareness.go
+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) {
M ysweet/proxy.go
+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()