Skip to content

[client] Add dialWebSocket method to WASM client#5980

Open
lixmal wants to merge 1 commit intomainfrom
wasm-websocket-dial
Open

[client] Add dialWebSocket method to WASM client#5980
lixmal wants to merge 1 commit intomainfrom
wasm-websocket-dial

Conversation

@lixmal
Copy link
Copy Markdown
Collaborator

@lixmal lixmal commented Apr 24, 2026

Describe your changes

Adds a dialWebSocket(url, protocols?, timeoutMs?) method to the WASM client. Opens a WebSocket over a NetBird TCP connection (via client.Dial) using gobwas/ws for the handshake and framing, and exposes a small send / close / onmessage / onclose / onerror interface to JS.

  • Add client/wasm/internal/websocket package wrapping a netbird net.Conn with WS framing
  • Expose dialWebSocket from the WASM client with optional subprotocols and timeout
  • Add gobwas/ws as a dependency

Why a custom WS layer and not the browser's native WebSocket

A browser-native WebSocket opens its own TCP/TLS connection through the browser's network stack, which bypasses the NetBird tunnel. That's unusable for reaching peers inside the overlay.

Alternatives considered:

  • Browser-native WebSocket — opens its own socket outside the NetBird tunnel.
  • nhooyr/coder-websocket — has a js build tag that delegates to the native browser WebSocket, so it hits the same problem.
  • Expose client.Dial as a raw duplex stream to JS and implement WS framing in TypeScript — doable but we'd be reimplementing framing, masking, and control-frame handling just to replace a thin Go wrapper. No existing JS WS client library can use a custom transport as its socket, so any approach on the JS side ends up being bespoke.

Going through Go with gobwas/ws is a small amount of code that handles framing on bytes alone, with no dependency on browser networking APIs, so it works fine in the wasm target over a netbird net.Conn.

Issue ticket number and link

Stack

Checklist

  • Is it a bug fix
  • Is a typo/documentation fix
  • Is a feature enhancement
  • It is a refactor
  • Created tests that fail without the change (if possible)

By submitting this pull request, you confirm that you have read and agree to the terms of the Contributor License Agreement.

Documentation

Select exactly one:

  • I added/updated documentation for this change
  • Documentation is not needed for this change (explain why)

Internal WASM API, no public-facing docs page.

Docs PR URL (required if "docs added" is checked)

Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__

Summary by CodeRabbit

  • New Features
    • WASM client now supports WebSocket connections for secure real-time bidirectional communication.
    • Promise-based async JS API to open connections using a URL, optional subprotocols, and an optional timeout override.
    • Messaging supports text and binary sends, with automatic control-frame handling and idempotent close behavior.
    • Runtime callbacks for onmessage, onclose (with code/reason), and onerror for robust client-side handling.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Adds a WASM-exposed dialWebSocket method and a new WASM-only WebSocket wrapper (Dial, Conn, NewJSInterface) that dials NetBird-backed connections and exposes a JS interface; updates go.mod to add gobwas/ws and related modules.

Changes

Cohort / File(s) Summary
WASM Client API
client/wasm/cmd/main.go
Adds dialWebSocket JS method: validates URL, optional protocols array -> []string, optional positive timeoutMs, creates Promise with context.WithTimeout, calls nbwebsocket.Dial, rejects on errors, resolves with NewJSInterface(conn).
WebSocket Wrapper (WASM-only)
client/wasm/internal/websocket/websocket.go
New JS-only WebSocket implementation: Dial(ctx, client, rawURL, protocols), Conn type with ReadMessage, WriteText, WriteBinary, idempotent Close, handshake-buffered reads, control-frame handling (auto Pong, typed Close), and NewJSInterface exposing send, close, onmessage, onclose, onerror plus background read loop.
Dependencies
go.mod
Adds github.com/gobwas/ws (and related modules) to support the new websocket implementation.

Sequence Diagram

sequenceDiagram
    participant JS as JavaScript (Browser)
    participant WASM as WASM module
    participant NB as NetBird Client
    participant Conn as WebSocket Conn
    participant RL as Read Loop

    JS->>WASM: dialWebSocket(url, protocols?, timeout?)
    WASM->>WASM: validate inputs, create timeout context
    WASM->>NB: Dial underlying stream
    NB-->>WASM: net.Conn
    WASM->>Conn: perform WS handshake (optional protocols)
    Conn-->>WASM: established Conn
    WASM-->>JS: resolve Promise with JS interface (send/close/events)

    par background read loop
        RL->>Conn: ReadMessage loop
        alt Text
            Conn-->>RL: text payload
            RL->>JS: onmessage(string)
        else Binary
            Conn-->>RL: binary payload
            RL->>JS: onmessage(Uint8Array)
        else Close
            Conn-->>RL: close(code, reason)
            RL->>JS: onclose(code, reason)
        else Error
            Conn-->>RL: read error
            RL->>JS: onerror(error)
        end
    end

    JS->>Conn: send(data) / close()
    Conn->>Conn: WriteText/WriteBinary / Close (idempotent)
    Conn->>NB: frame writes over underlying connection
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I nudged a URL and opened a gate,
Streams of bytes now hop and skate,
I pong and close with tidy grace,
Messages leap from place to place,
Hooray — new webs connect the space! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: adding a dialWebSocket method to the WASM client.
Description check ✅ Passed The description covers all template sections with substantive detail: changes explained, issue/stack acknowledged, feature enhancement marked, tests noted as not applicable, and documentation decision justified.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch wasm-websocket-dial

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@lixmal lixmal force-pushed the wasm-websocket-dial branch from 50f6577 to a6cb0bd Compare April 24, 2026 09:08
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
client/wasm/cmd/main.go (1)

573-587: Cache v.Length() to avoid redundant JS calls.

Each v.Length() call crosses the Go↔JS boundary via syscall/js. The loop header re-invokes it on every iteration; caching it once is both a minor perf win and more idiomatic.

♻️ Suggested refactor
 func jsStringArray(v js.Value) ([]string, error) {
 	if !v.InstanceOf(js.Global().Get("Array")) {
 		return nil, fmt.Errorf("expected array")
 	}
-	out := make([]string, v.Length())
-	for i := 0; i < v.Length(); i++ {
+	n := v.Length()
+	out := make([]string, n)
+	for i := 0; i < n; i++ {
 		el := v.Index(i)
 		if el.Type() != js.TypeString {
 			return nil, fmt.Errorf("element %d is not a string", i)
 		}
 		out[i] = el.String()
 	}
 	return out, nil
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/cmd/main.go` around lines 573 - 587, The jsStringArray function
makes repeated calls to v.Length() across the Go↔JS boundary; cache the length
once (e.g., n := v.Length()) before allocating out and entering the for loop,
then use that cached n in make([]string, n) and in the loop condition (for i :=
0; i < n; i++) to avoid redundant syscall/js calls and improve performance.
client/wasm/internal/websocket/websocket.go (1)

196-255: Optional: split readLoop to reduce cognitive complexity.

SonarCloud flags cognitive complexity 25 (limit 20). Extracting the close-frame handling and message dispatch into small helpers (e.g., handleReadError, dispatchMessage) would make the control flow easier to follow and bring it under the threshold. Non-blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 196 - 255, The
readLoop function is over the cognitive complexity threshold; refactor by
extracting the error/close-frame handling and the message dispatch into small
helpers: e.g., implement handleReadError(err error, conn *Conn, obj js.Value)
that detects a *closeError, sets gotCloseFrame/closeCode/closeReason (or returns
those values) and performs the RFC6455 conn.Close() response, and implement
dispatchMessage(op ws.OpCode, payload []byte, obj js.Value) that looks up
obj.Get("onmessage") and invokes the right JS value for ws.OpText and
ws.OpBinary (using js.CopyBytesToJS for binary). Replace the corresponding
inline blocks in readLoop (the errors.As closeError branch plus the onmessage
switch) with calls to these helpers while preserving the existing onclose
deferred logic and conn.closed check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Line 188: The error message returned from the send branch currently only
mentions "string or Uint8Array" but jsToBytes accepts ArrayBuffer as well;
update the error text in the send path (the return in websocket.go) to include
ArrayBuffer so callers see "use string, Uint8Array, or ArrayBuffer". Locate the
send-related function and the jsToBytes usage to ensure the message matches the
accepted types and adjust the fmt.Errorf message accordingly.
- Around line 232-238: The error handling should use errors.Is to detect EOF and
skip treating net.ErrClosed as an error: change the condition that currently
checks "if err != io.EOF { ... onerror.Invoke(...)" to use errors.Is(err,
io.EOF) and also ignore net.ErrClosed (i.e., if errors.Is(err, net.ErrClosed) or
errors.Is(err, io.EOF) then simply return without calling the JS onerror
callback). Ensure you import the errors and net packages and keep the existing
obj.Get("onerror")/onerror.Invoke(js.ValueOf(err.Error())) behavior for real
errors.
- Around line 47-59: The buffered reader returned by d.Dial (seen in the Dial
call that assigns conn, br, _, err) may contain server frames from the upgrade;
calling ws.PutReader(br) unconditionally discards them and later
wsutil.ReadServerMessage on Conn.conn will miss those frames. Fix: before
returning from the function that builds &Conn (the Dial handling code), check
br.Buffered() > 0; if buffered data exists either (a) wrap conn in an
io.Reader/ReadCloser that first serves br's buffered bytes and then delegates to
conn, or (b) drain frames from br using ws.ReadFrame loop and process/queue them
into Conn before calling ws.PutReader(br). Ensure ws.PutReader(br) is only
called after buffered bytes are consumed or preserved so Conn.conn+Conn methods
(e.g., wsutil.ReadServerMessage) will see the server-sent frames.

---

Nitpick comments:
In `@client/wasm/cmd/main.go`:
- Around line 573-587: The jsStringArray function makes repeated calls to
v.Length() across the Go↔JS boundary; cache the length once (e.g., n :=
v.Length()) before allocating out and entering the for loop, then use that
cached n in make([]string, n) and in the loop condition (for i := 0; i < n; i++)
to avoid redundant syscall/js calls and improve performance.

In `@client/wasm/internal/websocket/websocket.go`:
- Around line 196-255: The readLoop function is over the cognitive complexity
threshold; refactor by extracting the error/close-frame handling and the message
dispatch into small helpers: e.g., implement handleReadError(err error, conn
*Conn, obj js.Value) that detects a *closeError, sets
gotCloseFrame/closeCode/closeReason (or returns those values) and performs the
RFC6455 conn.Close() response, and implement dispatchMessage(op ws.OpCode,
payload []byte, obj js.Value) that looks up obj.Get("onmessage") and invokes the
right JS value for ws.OpText and ws.OpBinary (using js.CopyBytesToJS for
binary). Replace the corresponding inline blocks in readLoop (the errors.As
closeError branch plus the onmessage switch) with calls to these helpers while
preserving the existing onclose deferred logic and conn.closed check.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d5d2a4a7-9b67-4f71-9ea7-fe13cfbe607e

📥 Commits

Reviewing files that changed from the base of the PR and between f732b01 and 50f6577.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod

Comment thread client/wasm/internal/websocket/websocket.go
Comment thread client/wasm/internal/websocket/websocket.go Outdated
Comment thread client/wasm/internal/websocket/websocket.go Outdated
@lixmal lixmal force-pushed the wasm-websocket-dial branch from a6cb0bd to 4980336 Compare April 24, 2026 09:13
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/wasm/internal/websocket/websocket.go (1)

143-166: send return type is inconsistent.

On success it returns true, on write failure false, but on argument/type errors it returns a human-readable string ("send requires...", or err.Error() from jsToBytes). JS callers then cannot safely distinguish success from failure with a simple truthiness check — a non-empty error string is truthy. Consider returning false (and log.Errorf-ing the reason) for all failure paths, or throwing a JS error via js.Global().Get("Error").New(...).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 143 - 166, The send
JS function currently returns mixed types (string on arg/type errors, bool on
success/fail), break this by making all failure paths return js.ValueOf(false)
and logging the reason via log.Errorf; specifically, in the obj.Set("send",
js.FuncOf(...)) handler update the missing-argument branch to log an error and
return js.ValueOf(false), change the jsToBytes error branch to
log.Errorf("failed to convert JS value to bytes: %v", err) and return
js.ValueOf(false), and keep the existing conn.WriteText/conn.WriteBinary failure
branches but ensure they also return js.ValueOf(false) (already present) so
callers can reliably use truthiness to detect success.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 143-173: The send/close js.FuncOf callbacks created inline in
dialWebSocket are never released and leak; store each js.Func (e.g., sendFunc
and closeFunc) before calling obj.Set("send", ...) and obj.Set("close", ...),
and ensure you call sendFunc.Release() and closeFunc.Release() when the
connection is closed—best place is when readLoop exits (or immediately after
conn.Close() completes) so the callbacks are removed from Go's callback table;
update dialWebSocket to create named js.Funcs and release them on connection
teardown.

---

Nitpick comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 143-166: The send JS function currently returns mixed types
(string on arg/type errors, bool on success/fail), break this by making all
failure paths return js.ValueOf(false) and logging the reason via log.Errorf;
specifically, in the obj.Set("send", js.FuncOf(...)) handler update the
missing-argument branch to log an error and return js.ValueOf(false), change the
jsToBytes error branch to log.Errorf("failed to convert JS value to bytes: %v",
err) and return js.ValueOf(false), and keep the existing
conn.WriteText/conn.WriteBinary failure branches but ensure they also return
js.ValueOf(false) (already present) so callers can reliably use truthiness to
detect success.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8a573371-2fb1-4474-be68-1acc297f2835

📥 Commits

Reviewing files that changed from the base of the PR and between 50f6577 and a6cb0bd.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod
✅ Files skipped from review due to trivial changes (1)
  • go.mod
🚧 Files skipped from review as they are similar to previous changes (1)
  • client/wasm/cmd/main.go

Comment thread client/wasm/internal/websocket/websocket.go Outdated
@lixmal lixmal force-pushed the wasm-websocket-dial branch from 4980336 to 3373329 Compare April 24, 2026 09:22
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
client/wasm/cmd/main.go (1)

550-556: Type-check args[2] before calling .Int().

js.Value.Int() panics when the underlying JS value is not a number. A JS caller passing dialWebSocket(url, [], "5000") will crash the WASM runtime rather than get a clean promise rejection. Validate with args[2].Type() != js.TypeNumber (same pattern used for port elsewhere) before extracting.

🛡️ Proposed fix
 		timeout := dialWebSocketTimeout
 		if len(args) >= 3 && !args[2].IsNull() && !args[2].IsUndefined() {
-			timeoutMs := args[2].Int()
+			if args[2].Type() != js.TypeNumber {
+				return js.ValueOf("error: timeoutMs must be a number")
+			}
+			timeoutMs := args[2].Int()
 			if timeoutMs <= 0 {
 				return js.ValueOf("error: timeout must be positive")
 			}
 			timeout = time.Duration(timeoutMs) * time.Millisecond
 		}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/cmd/main.go` around lines 550 - 556, The code reads timeoutMs via
args[2].Int() which will panic if args[2] is not a JS number; before calling
Int() check args[2].Type() == js.TypeNumber (same pattern used for port
handling) and if it's not a number return a JS error/promise rejection like
"error: timeout must be a positive number"; only then parse timeoutMs, validate
timeoutMs > 0 and set timeout = time.Duration(timeoutMs) * time.Millisecond to
avoid runtime panics when callers pass non-number values to dialWebSocket.
client/wasm/internal/websocket/websocket.go (2)

61-66: Return br to the pool when there are no buffered bytes.

br is always wrapped in bufferedConn, even when br.Buffered() == 0. That adds an unnecessary reader layer and—more importantly—ws.PutReader(br) is never called, so the internal *bufio.Reader pool is never replenished. Since this is an internal micro‑optimization in gobwas/ws, the cost is low, but the intent stated in the comment ("drained before reading from conn") is only meaningful when br actually has data.

♻️ Proposed tweak
-	if br != nil {
-		conn = &bufferedConn{Conn: conn, r: io.MultiReader(br, conn)}
-	}
+	if br != nil {
+		if br.Buffered() > 0 {
+			conn = &bufferedConn{Conn: conn, r: io.MultiReader(br, conn)}
+		} else {
+			ws.PutReader(br)
+		}
+	}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 61 - 66, The
current block always wraps br in bufferedConn even when br.Buffered() == 0,
leaking bufio.Reader instances and adding an unnecessary layer; change the logic
in the conditional that handles br so that if br != nil and br.Buffered() == 0
you call ws.PutReader(br) and do not wrap conn, otherwise (br != nil and
br.Buffered() > 0) wrap conn = &bufferedConn{Conn: conn, r: io.MultiReader(br,
conn)}; ensure you reference br, bufferedConn, conn, and ws.PutReader in the
updated conditional so readers are returned to the pool when empty and only
wrapped when they actually contain buffered bytes.

191-198: JS properties retain references to released callbacks; consider undefined-ing them earlier.

When readLoop exits, sendFunc and closeFunc are released, but the JS object still has obj.send and obj.close pointing at them. Per Go's syscall/js documentation, invoking a released function logs "call to released function" to the browser console and returns without executing the handler.

While this is safe from a crash perspective, the JS caller experiences silent failure—no exception is thrown. For consistency with standard WebSocket semantics (where send() after close raises InvalidStateError), consider setting these properties to js.Undefined() before releasing, so subsequent calls surface an explicit TypeError in JavaScript.

♻️ Suggested tweak
 	go func() {
-		defer sendFunc.Release()
-		defer closeFunc.Release()
 		readLoop(conn, obj)
+		// Clear JS properties before releasing so post-close calls
+		// raise TypeError instead of failing silently.
+		obj.Set("send", js.Undefined())
+		obj.Set("close", js.Undefined())
+		sendFunc.Release()
+		closeFunc.Release()
 	}()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 191 - 198, The JS
callbacks on the exported websocket object still point to released Go functions;
update the goroutine that calls readLoop so that before releasing sendFunc and
closeFunc you set the JS properties to js.Undefined() (i.e., call
obj.Set("send", js.Undefined()) and obj.Set("close", js.Undefined())), then call
sendFunc.Release() and closeFunc.Release(); keep readLoop(conn, obj) behavior
but ensure the undefineding happens prior to Release() to surface a TypeError to
JS callers instead of a silent "call to released function".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 216-265: The onclose invocation is inconsistent: update
handleReadError/readLoop to always produce a closeError (or non-nil sentinel) so
invokeOnClose always receives a code and reason; specifically, in
handleReadError map io.EOF and net.ErrClosed to a closeError with code 1005 and
empty reason, map other non-WebSocket-close errors to a closeError with code
1006 and reason err.Error(), keep the existing branch for errors.As(err, &ce)
returning server-provided closeError, and ensure invokeOnClose (used by
readLoop) then always calls onclose with int(ce.code) and ce.reason so JS
handlers receive a consistent (code, reason) tuple.

---

Nitpick comments:
In `@client/wasm/cmd/main.go`:
- Around line 550-556: The code reads timeoutMs via args[2].Int() which will
panic if args[2] is not a JS number; before calling Int() check args[2].Type()
== js.TypeNumber (same pattern used for port handling) and if it's not a number
return a JS error/promise rejection like "error: timeout must be a positive
number"; only then parse timeoutMs, validate timeoutMs > 0 and set timeout =
time.Duration(timeoutMs) * time.Millisecond to avoid runtime panics when callers
pass non-number values to dialWebSocket.

In `@client/wasm/internal/websocket/websocket.go`:
- Around line 61-66: The current block always wraps br in bufferedConn even when
br.Buffered() == 0, leaking bufio.Reader instances and adding an unnecessary
layer; change the logic in the conditional that handles br so that if br != nil
and br.Buffered() == 0 you call ws.PutReader(br) and do not wrap conn, otherwise
(br != nil and br.Buffered() > 0) wrap conn = &bufferedConn{Conn: conn, r:
io.MultiReader(br, conn)}; ensure you reference br, bufferedConn, conn, and
ws.PutReader in the updated conditional so readers are returned to the pool when
empty and only wrapped when they actually contain buffered bytes.
- Around line 191-198: The JS callbacks on the exported websocket object still
point to released Go functions; update the goroutine that calls readLoop so that
before releasing sendFunc and closeFunc you set the JS properties to
js.Undefined() (i.e., call obj.Set("send", js.Undefined()) and obj.Set("close",
js.Undefined())), then call sendFunc.Release() and closeFunc.Release(); keep
readLoop(conn, obj) behavior but ensure the undefineding happens prior to
Release() to surface a TypeError to JS callers instead of a silent "call to
released function".
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b7cdedc5-3f3b-4b23-b90a-c3984044c8bc

📥 Commits

Reviewing files that changed from the base of the PR and between a6cb0bd and 3373329.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod
✅ Files skipped from review due to trivial changes (1)
  • go.mod

Comment thread client/wasm/internal/websocket/websocket.go
@lixmal lixmal force-pushed the wasm-websocket-dial branch 2 times, most recently from 9962c77 to a463c29 Compare April 24, 2026 09:41
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/wasm/cmd/main.go (1)

530-574: Optional: group the new timeout constant and trim cognitive complexity.

Two small refactors:

  • dialWebSocketTimeout is declared in isolation at line 530; the other timeouts (clientStartTimeout, clientStopTimeout, pingTimeout, defaultSSHDetectionTimeout) live in the top const block at lines 24–34. Moving it there is more consistent.
  • SonarCloud flags cognitive complexity 21 (limit 20) on createDialWebSocketMethod. Extracting the protocols/timeout arg parsing into helpers drops it below the threshold and improves readability.
♻️ Proposed refactor
 const (
 	clientStartTimeout         = 30 * time.Second
 	clientStopTimeout          = 10 * time.Second
 	pingTimeout                = 10 * time.Second
 	defaultLogLevel            = "warn"
 	defaultSSHDetectionTimeout = 20 * time.Second
+	dialWebSocketTimeout       = 30 * time.Second
 
 	icmpEchoRequest = 8
 	icmpCodeEcho    = 0
 	pingBufferSize  = 1500
 )
-const dialWebSocketTimeout = 30 * time.Second
-
 func createDialWebSocketMethod(client *netbird.Client) js.Func {
 	return js.FuncOf(func(_ js.Value, args []js.Value) any {
 		if len(args) < 1 || args[0].Type() != js.TypeString {
 			return js.ValueOf("error: dialWebSocket requires a URL string argument")
 		}
-
 		url := args[0].String()
 
-		var protocols []string
-		if len(args) >= 2 && !args[1].IsNull() && !args[1].IsUndefined() {
-			arr, err := jsStringArray(args[1])
-			if err != nil {
-				return js.ValueOf(fmt.Sprintf("error: protocols: %v", err))
-			}
-			protocols = arr
-		}
-
-		timeout := dialWebSocketTimeout
-		if len(args) >= 3 && !args[2].IsNull() && !args[2].IsUndefined() {
-			if args[2].Type() != js.TypeNumber {
-				return js.ValueOf("error: timeoutMs must be a number")
-			}
-			timeoutMs := args[2].Int()
-			if timeoutMs <= 0 {
-				return js.ValueOf("error: timeout must be positive")
-			}
-			timeout = time.Duration(timeoutMs) * time.Millisecond
-		}
+		protocols, perr := parseProtocolsArg(args)
+		if perr != nil {
+			return js.ValueOf(perr.Error())
+		}
+		timeout, terr := parseTimeoutArg(args, dialWebSocketTimeout)
+		if terr != nil {
+			return js.ValueOf(terr.Error())
+		}
 
 		return createPromise(func(resolve, reject js.Value) {
 			ctx, cancel := context.WithTimeout(context.Background(), timeout)
 			defer cancel()
 
 			conn, err := nbwebsocket.Dial(ctx, client, url, protocols)
 			if err != nil {
 				reject.Invoke(js.ValueOf(fmt.Sprintf("dial websocket: %v", err)))
 				return
 			}
 
 			resolve.Invoke(nbwebsocket.NewJSInterface(conn))
 		})
 	})
 }
+
+func parseProtocolsArg(args []js.Value) ([]string, error) {
+	if len(args) < 2 || args[1].IsNull() || args[1].IsUndefined() {
+		return nil, nil
+	}
+	arr, err := jsStringArray(args[1])
+	if err != nil {
+		return nil, fmt.Errorf("error: protocols: %w", err)
+	}
+	return arr, nil
+}
+
+func parseTimeoutArg(args []js.Value, def time.Duration) (time.Duration, error) {
+	if len(args) < 3 || args[2].IsNull() || args[2].IsUndefined() {
+		return def, nil
+	}
+	if args[2].Type() != js.TypeNumber {
+		return 0, fmt.Errorf("error: timeoutMs must be a number")
+	}
+	ms := args[2].Int()
+	if ms <= 0 {
+		return 0, fmt.Errorf("error: timeout must be positive")
+	}
+	return time.Duration(ms) * time.Millisecond, nil
+}

Based on static analysis hint: SonarCloud flagged cognitive complexity 21 exceeds the allowed 20.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/cmd/main.go` around lines 530 - 574, Move the
dialWebSocketTimeout constant into the existing top const block with
clientStartTimeout/clientStopTimeout/pingTimeout/defaultSSHDetectionTimeout for
consistency, and reduce cognitive complexity in createDialWebSocketMethod by
extracting the protocol and timeout parsing into small helpers (e.g.,
parseProtocolsArg(js.Value) ([]string, error) and parseTimeoutArg(js.Value,
default time.Duration) (time.Duration, error)); have createDialWebSocketMethod
call these helpers (and still validate arg types/values and return the same
error strings) so the main function focuses only on promise creation and dialing
and the overall complexity drops below the SonarCloud threshold.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 195-203: The goroutine calling readLoop(conn, obj) doesn't ensure
conn.Close() on non-close-frame exit paths; add an unconditional defer
conn.Close() at the start of that goroutine (before calling readLoop) so the
underlying net.Conn is closed for EOF, net.ErrClosed, or other read errors—this
is safe because Conn.Close is idempotent via closeOnce; keep the existing
obj.Set("send"/"close") and sendFunc.Release()/closeFunc.Release() cleanup after
readLoop returns.

---

Nitpick comments:
In `@client/wasm/cmd/main.go`:
- Around line 530-574: Move the dialWebSocketTimeout constant into the existing
top const block with
clientStartTimeout/clientStopTimeout/pingTimeout/defaultSSHDetectionTimeout for
consistency, and reduce cognitive complexity in createDialWebSocketMethod by
extracting the protocol and timeout parsing into small helpers (e.g.,
parseProtocolsArg(js.Value) ([]string, error) and parseTimeoutArg(js.Value,
default time.Duration) (time.Duration, error)); have createDialWebSocketMethod
call these helpers (and still validate arg types/values and return the same
error strings) so the main function focuses only on promise creation and dialing
and the overall complexity drops below the SonarCloud threshold.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cf63aab2-8e35-44d5-a62f-6e808b5e98ad

📥 Commits

Reviewing files that changed from the base of the PR and between 3373329 and 9962c77.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod
✅ Files skipped from review due to trivial changes (1)
  • go.mod

Comment thread client/wasm/internal/websocket/websocket.go
@lixmal lixmal force-pushed the wasm-websocket-dial branch 2 times, most recently from 4b06d0f to 55db2fa Compare April 24, 2026 09:49
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 252-268: handleReadError currently calls conn.Close() on receiving
a server close frame, which sends StatusNormalClosure; change it to mirror the
server's close code/reason by invoking a close method that accepts the received
code and reason (e.g., use or add a method like Conn.CloseWith(code int, reason
string) or extend Conn.Close to accept those params) and pass the fields from
the parsed closeError (closeError.Code and closeError.Reason) when ce is
non-nil; keep the existing debug/error path but ensure the echoed close frame
uses ce.Code and ce.Reason instead of the fixed StatusNormalClosure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1563b869-09ed-4286-8959-29919d2a6776

📥 Commits

Reviewing files that changed from the base of the PR and between 9962c77 and 4b06d0f.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod
✅ Files skipped from review due to trivial changes (1)
  • go.mod

Comment thread client/wasm/internal/websocket/websocket.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
client/wasm/internal/websocket/websocket.go (2)

60-70: Minor: br pool reuse is skipped when handshake buffered frames exist.

When br.Buffered() > 0, the *bufio.Reader is retained inside bufferedConn via io.MultiReader and never returned to the pool via ws.PutReader. It'll be GC'd eventually, so this is not a leak — just a missed pool-reuse opportunity. If you care about steady-state allocations, you could swap the MultiReader for a small wrapper that drains br first, then calls ws.PutReader(br) and falls through to conn. Feel free to ignore if not worth the complexity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 60 - 70, When
br.Buffered() > 0 you currently wrap conn with io.MultiReader(br, conn) and
never return br to the ws pool; replace that with a small wrapper reader (e.g.,
drainThenPutReader) used by bufferedConn that reads from br until exhausted,
calls ws.PutReader(br) immediately after the last byte is consumed, then
delegates to conn; update the bufferedConn construction to use this wrapper
instead of io.MultiReader and ensure br is always returned to the pool once
drained.

295-301: Use gobwas/ws helpers for cleaner close frame parsing.

parseClosePayload duplicates ws.ParseCloseFrameData from the already-imported gobwas/ws library, which handles the <2 bytes → StatusNoStatusRcvd case. Using the library helper plus ws.StatusNoStatusRcvd replaces the magic 1005 constant and keeps parsing consistent with the rest of the gobwas/ws surface used throughout this file.

♻️ Optional refactor
-func parseClosePayload(payload []byte) (uint16, string) {
-	if len(payload) < 2 {
-		return 1005, "" // RFC 6455: No Status Rcvd
-	}
-	code := binary.BigEndian.Uint16(payload[:2])
-	return code, string(payload[2:])
-}
+func parseClosePayload(payload []byte) (uint16, string) {
+	if len(payload) < 2 {
+		return uint16(ws.StatusNoStatusRcvd), ""
+	}
+	code, reason := ws.ParseCloseFrameData(payload)
+	return uint16(code), reason
+}

If adopted, the encoding/binary import can be removed.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 295 - 301, Replace
the custom parseClosePayload implementation with the gobwas/ws helper: call
ws.ParseCloseFrameData(payload) and return its parsed code and reason, using
ws.StatusNoStatusRcvd instead of the hard-coded 1005; update any return handling
to match ws.ParseCloseFrameData's signature and remove the now-unused
encoding/binary import. Ensure you reference and use ws.ParseCloseFrameData and
ws.StatusNoStatusRcvd in the parseClosePayload function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 60-70: When br.Buffered() > 0 you currently wrap conn with
io.MultiReader(br, conn) and never return br to the ws pool; replace that with a
small wrapper reader (e.g., drainThenPutReader) used by bufferedConn that reads
from br until exhausted, calls ws.PutReader(br) immediately after the last byte
is consumed, then delegates to conn; update the bufferedConn construction to use
this wrapper instead of io.MultiReader and ensure br is always returned to the
pool once drained.
- Around line 295-301: Replace the custom parseClosePayload implementation with
the gobwas/ws helper: call ws.ParseCloseFrameData(payload) and return its parsed
code and reason, using ws.StatusNoStatusRcvd instead of the hard-coded 1005;
update any return handling to match ws.ParseCloseFrameData's signature and
remove the now-unused encoding/binary import. Ensure you reference and use
ws.ParseCloseFrameData and ws.StatusNoStatusRcvd in the parseClosePayload
function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7fd82812-981e-4423-a222-b1250dce98ab

📥 Commits

Reviewing files that changed from the base of the PR and between 9962c77 and 55db2fa.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod
✅ Files skipped from review due to trivial changes (2)
  • go.mod
  • client/wasm/cmd/main.go

@lixmal lixmal force-pushed the wasm-websocket-dial branch from 55db2fa to fbd74d3 Compare April 24, 2026 10:06
@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
client/wasm/internal/websocket/websocket.go (1)

152-159: Doc comment missed ArrayBuffer.

jsToBytes accepts Uint8Array and ArrayBuffer, but the doc comment advertises only string|Uint8Array.

✏️ Suggested tweak
 // NewJSInterface creates a JavaScript object wrapping the WebSocket connection.
-// It exposes: send(string|Uint8Array), close(), and callback properties
-// onmessage, onclose, onerror.
+// It exposes: send(string|Uint8Array|ArrayBuffer), close(), and callback
+// properties onmessage, onclose, onerror.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/wasm/internal/websocket/websocket.go` around lines 152 - 159, Update
the doc comment for NewJSInterface to reflect that jsToBytes accepts both
Uint8Array and ArrayBuffer (in addition to string), e.g., change the exposed
send type description from "string|Uint8Array" to
"string|Uint8Array|ArrayBuffer" and/or add a note referencing jsToBytes so the
documentation matches the actual accepted input types.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@client/wasm/internal/websocket/websocket.go`:
- Around line 152-159: Update the doc comment for NewJSInterface to reflect that
jsToBytes accepts both Uint8Array and ArrayBuffer (in addition to string), e.g.,
change the exposed send type description from "string|Uint8Array" to
"string|Uint8Array|ArrayBuffer" and/or add a note referencing jsToBytes so the
documentation matches the actual accepted input types.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dbe0b2de-30ed-4c34-9544-c471d5ba9f8a

📥 Commits

Reviewing files that changed from the base of the PR and between 55db2fa and fbd74d3.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (3)
  • client/wasm/cmd/main.go
  • client/wasm/internal/websocket/websocket.go
  • go.mod
✅ Files skipped from review due to trivial changes (1)
  • go.mod

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant