Skip to content

Enable dual-stack IPv4/IPv6 listening for backend and Vite#401

Draft
stephenfeather wants to merge 1 commit intositeboon:mainfrom
stephenfeather:feat-ipv4-ipv6-dualstack
Draft

Enable dual-stack IPv4/IPv6 listening for backend and Vite#401
stephenfeather wants to merge 1 commit intositeboon:mainfrom
stephenfeather:feat-ipv4-ipv6-dualstack

Conversation

@stephenfeather
Copy link

@stephenfeather stephenfeather commented Feb 19, 2026

Summary

  • enable dual-stack backend listening by preferring host :: with ipv6Only: false, and fallback to 0.0.0.0 when IPv6 is unavailable
  • set Vite dev server host to :: for IPv4 + IPv6 reachability
  • remove the package script host override so Vite config host is respected

Validation

Related issue: #399

Summary by CodeRabbit

  • Chores
    • Improved development server network binding with IPv6 support and automatic IPv4 fallback
    • Enhanced server startup logging to display actual listening host and port information
    • Updated development server configuration for better network interface handling

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 19, 2026

📝 Walkthrough

Walkthrough

The changes implement IPv6-first server binding with IPv4 fallback. The Node server now attempts to listen on IPv6 (::) initially, falling back to IPv4 (0.0.0.0) if IPv6 is unavailable. The Vite configuration defaults to IPv6, and the CLI host flag is removed as redundant.

Changes

Cohort / File(s) Summary
Network Configuration
vite.config.js, package.json
Vite dev server now defaults to IPv6 (::) via config; removed redundant --host CLI flag
Server Binding Logic
server/index.js
Added listenWithBestAvailableHost() helper that attempts IPv6 binding first with automatic IPv4 fallback on unsupported platforms; updated startup logging to display actual listening host and port

Possibly related issues

Poem

🐰 A server hops through IPv6 gates,
When protocols won't cooperate,
It bounds to IPv4's embrace,
Finding the best available place! 🌐

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main objective: enabling dual-stack IPv4/IPv6 listening. It is concise, specific, and directly reflects the core changes across both backend (server/index.js) and frontend (vite.config.js, package.json).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

Copy link
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: 2

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

Inline comments:
In `@server/index.js`:
- Around line 1939-1948: The startup log currently prints the raw bind address
(listenOptions.host / listenHostDisplay) which can be wildcards like '::' or
'0.0.0.0' and is not a usable URL; update the logging to detect wildcard
addresses and substitute a loopback equivalent before building the URL (e.g.,
map '::' -> '[::1]' and '0.0.0.0' -> '127.0.0.1') so the printed Server URL
(constructed with listenHostDisplay and PORT) is a usable URL; modify the code
that computes listenHostDisplay (or the final URL string) to perform this
mapping and keep existing bracketed IPv6 formatting for display.
- Around line 1893-1900: The IPv6 fallback condition in the handleError error
handler needs to include 'EADDRNOTAVAIL' so systems with IPv6 disabled fall back
to IPv4; update the conditional in handleError (the one checking error.code ===
'EAFNOSUPPORT' || error.code === 'EINVAL') to also check for error.code ===
'EADDRNOTAVAIL' before calling tryListen(fallbackOptions, null) and returning,
preserving the existing server.off('error', handleError) and reject/return
behavior.

Comment on lines +1893 to +1900
const handleError = (error) => {
server.off('error', handleError);

if (fallbackOptions && (error.code === 'EAFNOSUPPORT' || error.code === 'EINVAL')) {
console.warn(`${c.warn('[WARN]')} IPv6 bind unavailable, falling back to IPv4`);
tryListen(fallbackOptions, null);
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, read the code at lines 1890-1920 in server/index.js
cd /repo && head -1920 server/index.js | tail -31 | cat -n

Repository: siteboon/claudecodeui

Length of output: 120


🏁 Script executed:

# Also get context around the listenWithBestAvailableHost function
cd /repo && sed -n '1880,1920p' server/index.js | cat -n

Repository: siteboon/claudecodeui

Length of output: 120


🏁 Script executed:

# Run the verification script to check the actual error code
node -e "
const http = require('http');
const s = http.createServer();
s.listen({ port: 0, host: '::', ipv6Only: false }, () => {
    console.log('bind succeeded (IPv6 is enabled)');
    s.close();
}).on('error', (e) => {
    console.log('error.code:', e.code);
    console.log('error.message:', e.message);
    process.exit(0);
});

// Timeout in case bind succeeds (IPv6 is available)
setTimeout(() => {
    console.log('IPv6 appears to be enabled on this system');
    process.exit(0);
}, 2000);
"

Repository: siteboon/claudecodeui

Length of output: 139


🏁 Script executed:

# Find server/index.js
find . -name "index.js" -path "*/server/*" 2>/dev/null

Repository: siteboon/claudecodeui

Length of output: 82


🏁 Script executed:

# List repository structure
git ls-files | grep -E "(server|index)" | head -20

Repository: siteboon/claudecodeui

Length of output: 500


🏁 Script executed:

# Get file count to understand repo
git ls-files | head -1

Repository: siteboon/claudecodeui

Length of output: 77


🏁 Script executed:

# Read lines 1890-1920 from server/index.js
sed -n '1890,1920p' ./server/index.js | cat -n

Repository: siteboon/claudecodeui

Length of output: 1310


🏁 Script executed:

# Also check lines 1893-1900 mentioned in the review
sed -n '1893,1900p' ./server/index.js | cat -n

Repository: siteboon/claudecodeui

Length of output: 518


🏁 Script executed:

# Search for any existing handling of EADDRNOTAVAIL in the codebase
rg "EADDRNOTAVAIL" --type js

Repository: siteboon/claudecodeui

Length of output: 47


🏁 Script executed:

# Check Node.js version and net module documentation references in code
grep -r "engine\|node" ./package.json | head -5

Repository: siteboon/claudecodeui

Length of output: 244


🏁 Script executed:

# Look for any comments or discussions about IPv6 fallback
rg "IPv6\|ipv6\|EAFNOSUPPORT\|EINVAL" --type js --context 3

Repository: siteboon/claudecodeui

Length of output: 47


🌐 Web query:

Node.js net.Server bind EADDRNOTAVAIL IPv6 disabled sysctl

💡 Result:

When you call server.listen() (or omit host), Node will prefer the IPv6 “any” address :: when IPv6 is available, and on many OSes that single IPv6 socket also accepts IPv4 via dual‑stack. Node also exposes an ipv6Only option (default false) to control that dual‑stack behavior. [1]

If IPv6 is disabled at the kernel / interface level (common culprits: boot param ipv6.disable=1, or sysctls like net.ipv6.conf.all.disable_ipv6=1 / net.ipv6.conf.<if>.disable_ipv6=1), then binding to :: can fail and surface as EADDRNOTAVAIL (or sometimes EAFNOSUPPORT depending on how IPv6 is disabled). This is a known pain point for Node apps expecting dual‑stack from ::. [2][3]

Practical fixes

  1. Force IPv4 bind in Node (works even if IPv6 is fully disabled):
    server.listen({ port: 3000, host: '0.0.0.0' });
    // or '127.0.0.1' for local-only
  2. If you want “bind :: but effectively no IPv6 traffic”: keep the IPv6 socket API available, but disable IPv6 on interfaces via sysctl (so apps can still bind :: / dual-stack, while IPv6 addressing is off). Example sysctls often used:
    • net.ipv6.conf.all.disable_ipv6=1
    • net.ipv6.conf.default.disable_ipv6=1
      (Sometimes you may need an exception for lo.) [4]
  3. Fallback logic (robust across hosts where IPv6 may be disabled):
    • try ::
    • if error is EAFNOSUPPORT / EADDRNOTAVAIL, retry 0.0.0.0 (this workaround is commonly recommended). [3]

If you share the exact listen(...) call and the output of sysctl net.ipv6.conf.all.disable_ipv6 net.ipv6.conf.default.disable_ipv6, I can tell you which path applies.

Sources: Node.js net.Server listen() behavior and ipv6Only option [1]; Linux disable_ipv6 sysctl semantics [2]; Node issue describing failures when IPv6 is disabled (ipv6.disable=1) [3]; dual-stack binding strategy with IPv6 disabled on interfaces [4].


🌐 Web query:

Node.js error codes EADDRNOTAVAIL bind IPv6 :: Docker container

💡 Result:

EADDRNOTAVAIL when you do server.listen(port, '::') in a Docker container usually means the container doesn’t have IPv6 available/configured, so binding to the IPv6 “any” address (::) fails.

Fix options

  1. Prefer IPv4 inside the container (most common)
server.listen(process.env.PORT ?? 3000, '0.0.0.0');

In containers you generally bind to 0.0.0.0 (all IPv4 interfaces) and publish the port with Docker (-p hostPort:containerPort). This avoids depending on IPv6 being present. [1], [2]

  1. Let Node choose automatically
    If you omit the host, Node will listen on :: when IPv6 is available, otherwise it falls back to 0.0.0.0. [1]
server.listen(process.env.PORT ?? 3000);
  1. If you truly need IPv6, enable it in Docker
    Docker IPv6 requires daemon/network configuration (and is supported on Linux Docker daemons). For example, enable IPv6 on the daemon or create an IPv6-enabled network / Compose enable_ipv6: true. [3]

Sources: Node.js server.listen() behavior and ipv6Only notes [1]; container binding guidance (0.0.0.0) [2]; Docker IPv6 enablement docs [3].


Add EADDRNOTAVAIL to the IPv6 fallback condition — server crashes on IPv6-disabled systems instead of falling back to IPv4.

When net.ipv6.conf.all.disable_ipv6=1 is set (common in Docker containers without IPv6 networking, many CI runners, Kubernetes pods), the IPv6 socket type is available but bind() on :: fails with EADDRNOTAVAIL — not EAFNOSUPPORT or EINVAL. The current fallback condition only checks those two codes, so the error is never caught. The handler calls reject(error), startServer() catches it, and the process exits instead of falling back to 0.0.0.0.

🐛 Proposed fix
- if (fallbackOptions && (error.code === 'EAFNOSUPPORT' || error.code === 'EINVAL')) {
+ if (fallbackOptions && (error.code === 'EAFNOSUPPORT' || error.code === 'EINVAL' || error.code === 'EADDRNOTAVAIL')) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/index.js` around lines 1893 - 1900, The IPv6 fallback condition in the
handleError error handler needs to include 'EADDRNOTAVAIL' so systems with IPv6
disabled fall back to IPv4; update the conditional in handleError (the one
checking error.code === 'EAFNOSUPPORT' || error.code === 'EINVAL') to also check
for error.code === 'EADDRNOTAVAIL' before calling tryListen(fallbackOptions,
null) and returning, preserving the existing server.off('error', handleError)
and reject/return behavior.

Comment on lines +1939 to +1948
const listenHostDisplay = listenOptions.host.includes(':')
? `[${listenOptions.host}]`
: listenOptions.host;

console.log('');
console.log(c.dim('═'.repeat(63)));
console.log(` ${c.bright('Claude Code UI Server - Ready')}`);
console.log(c.dim('═'.repeat(63)));
console.log('');
console.log(`${c.info('[INFO]')} Server URL: ${c.bright('http://' + listenHostDisplay + ':' + PORT)}`);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Startup log shows the wildcard binding address, not a usable server URL.

When the server binds to ::, the log prints http://[::]:3001 — this is the wildcard any-address, not a loopback you can open. The PR's own validation used http://127.0.0.1:3001 and http://[::1]:3001, neither of which appears in the log. 0.0.0.0 in the IPv4 fallback case has the same problem.

💡 Suggested improvement — show loopback addresses instead
-       const listenHostDisplay = listenOptions.host.includes(':')
-           ? `[${listenOptions.host}]`
-           : listenOptions.host;
-
-       console.log('');
-       console.log(c.dim('═'.repeat(63)));
-       console.log(`  ${c.bright('Claude Code UI Server - Ready')}`);
-       console.log(c.dim('═'.repeat(63)));
-       console.log('');
-       console.log(`${c.info('[INFO]')} Server URL:  ${c.bright('http://' + listenHostDisplay + ':' + PORT)}`);
+       const isIPv6 = listenOptions.host.includes(':');
+
+       console.log('');
+       console.log(c.dim('═'.repeat(63)));
+       console.log(`  ${c.bright('Claude Code UI Server - Ready')}`);
+       console.log(c.dim('═'.repeat(63)));
+       console.log('');
+       console.log(`${c.info('[INFO]')} Server (IPv4): ${c.bright('http://127.0.0.1:' + PORT)}`);
+       if (isIPv6) {
+           console.log(`${c.info('[INFO]')} Server (IPv6): ${c.bright('http://[::1]:' + PORT)}`);
+       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/index.js` around lines 1939 - 1948, The startup log currently prints
the raw bind address (listenOptions.host / listenHostDisplay) which can be
wildcards like '::' or '0.0.0.0' and is not a usable URL; update the logging to
detect wildcard addresses and substitute a loopback equivalent before building
the URL (e.g., map '::' -> '[::1]' and '0.0.0.0' -> '127.0.0.1') so the printed
Server URL (constructed with listenHostDisplay and PORT) is a usable URL; modify
the code that computes listenHostDisplay (or the final URL string) to perform
this mapping and keep existing bracketed IPv6 formatting for display.

@blackmammoth blackmammoth requested a review from Copilot February 22, 2026 12:35
@blackmammoth
Copy link
Collaborator

@stephenfeather Thanks for this PR as well. Can you address the conflicts and also comments from CodeRabbit and Copilot here too?

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates local server binding defaults so both the backend (Node/Express HTTP server) and the Vite dev server are reachable via IPv4 and IPv6 by default, aligning with issue #399’s dual-stack expectation.

Changes:

  • Backend: add a helper to prefer host: '::' with ipv6Only: false, with IPv4 fallback to 0.0.0.0 when IPv6 bind isn’t supported.
  • Frontend: set Vite dev server server.host default to :: (overridable via VITE_HOST).
  • Tooling: remove the vite --host script override so vite.config.js host configuration is respected.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
vite.config.js Sets dev server host to :: (or VITE_HOST) for IPv4+IPv6 reachability.
server/index.js Replaces hardcoded IPv4 wildcard listening with dual-stack-first binding plus IPv4 fallback and updates startup URL logging accordingly.
package.json Removes CLI --host override from the client script so Vite config controls host binding.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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.

3 participants