Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg/cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,
lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe")
lc.cmd.Flags().IntVar(&lc.maxConnections, "max-connections", 50, "Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing)")

lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (only fatal errors)")
lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (errors and warnings only)")

lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)")
lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)")
Expand Down
29 changes: 29 additions & 0 deletions pkg/listen/healthcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package listen

import (
"net/url"
"time"

"github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck"
)

// Re-export types and constants from healthcheck subpackage for convenience
type ServerHealthStatus = healthcheck.ServerHealthStatus
type HealthCheckResult = healthcheck.HealthCheckResult

const (
HealthHealthy = healthcheck.HealthHealthy
HealthUnreachable = healthcheck.HealthUnreachable
)

// CheckServerHealth performs a TCP connection check to the target URL
// This is a wrapper around the healthcheck package function for backward compatibility
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
return healthcheck.CheckServerHealth(targetURL, timeout)
}

// FormatHealthMessage creates a user-friendly health status message
// This is a wrapper around the healthcheck package function for backward compatibility
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
return healthcheck.FormatHealthMessage(result, targetURL)
}
88 changes: 88 additions & 0 deletions pkg/listen/healthcheck/healthcheck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package healthcheck

import (
"fmt"
"net"
"net/url"
"os"
"time"

"github.com/hookdeck/hookdeck-cli/pkg/ansi"
)

// ServerHealthStatus represents the health status of the target server
type ServerHealthStatus int

const (
HealthHealthy ServerHealthStatus = iota // TCP connection successful
HealthUnreachable // Connection refused or timeout
)

// HealthCheckResult contains the result of a health check
type HealthCheckResult struct {
Status ServerHealthStatus
Healthy bool
Error error
Timestamp time.Time
Duration time.Duration
}

// CheckServerHealth performs a TCP connection check to verify a server is listening.
// The timeout parameter should be appropriate for the deployment context:
// - Local development: 3s is typically sufficient
// - Production/edge: May require longer timeouts due to network conditions
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
start := time.Now()

host := targetURL.Hostname()
port := targetURL.Port()

// Default ports if not specified
if port == "" {
if targetURL.Scheme == "https" {
port = "443"
} else {
port = "80"
}
}

address := net.JoinHostPort(host, port)

conn, err := net.DialTimeout("tcp", address, timeout)
duration := time.Since(start)

result := HealthCheckResult{
Timestamp: start,
Duration: duration,
}

if err != nil {
result.Healthy = false
result.Error = err
result.Status = HealthUnreachable
return result
}

// Successfully connected - server is healthy
conn.Close()
result.Healthy = true
result.Status = HealthHealthy
return result
}

// FormatHealthMessage creates a user-friendly health status message
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
if result.Healthy {
return fmt.Sprintf("→ Local server is reachable at %s", targetURL.String())
}

color := ansi.Color(os.Stdout)
errorMessage := "unknown error"
if result.Error != nil {
errorMessage = result.Error.Error()
}
return fmt.Sprintf("%s Cannot connect to local server at %s\n %s\n The server may not be running. Events will fail until the server starts.",
color.Yellow("● Warning:"),
targetURL.String(),
errorMessage)
}
199 changes: 199 additions & 0 deletions pkg/listen/healthcheck/healthcheck_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
package healthcheck

import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)

func TestCheckServerHealth_HealthyServer(t *testing.T) {
// Start a test HTTP server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

// Parse server URL
serverURL, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Failed to parse server URL: %v", err)
}

// Perform health check
result := CheckServerHealth(serverURL, 3*time.Second)

// Verify result
if !result.Healthy {
t.Errorf("Expected server to be healthy, got unhealthy")
}
if result.Status != HealthHealthy {
t.Errorf("Expected status HealthHealthy, got %v", result.Status)
}
if result.Error != nil {
t.Errorf("Expected no error, got: %v", result.Error)
}
if result.Duration <= 0 {
t.Errorf("Expected positive duration, got: %v", result.Duration)
}
}

func TestCheckServerHealth_UnreachableServer(t *testing.T) {
// Use a URL that should not be listening
targetURL, err := url.Parse("http://localhost:59999")
if err != nil {
t.Fatalf("Failed to parse URL: %v", err)
}

// Perform health check
result := CheckServerHealth(targetURL, 1*time.Second)

// Verify result
if result.Healthy {
t.Errorf("Expected server to be unhealthy, got healthy")
}
if result.Status != HealthUnreachable {
t.Errorf("Expected status HealthUnreachable, got %v", result.Status)
}
if result.Error == nil {
t.Errorf("Expected error, got nil")
}
}

func TestCheckServerHealth_DefaultPorts(t *testing.T) {
testCases := []struct {
name string
urlString string
expectedPort string
}{
{
name: "HTTP default port",
urlString: "http://localhost",
expectedPort: "80",
},
{
name: "HTTPS default port",
urlString: "https://localhost",
expectedPort: "443",
},
{
name: "Explicit port",
urlString: "http://localhost:8080",
expectedPort: "8080",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
targetURL, err := url.Parse(tc.urlString)
if err != nil {
t.Fatalf("Failed to parse URL: %v", err)
}

// Start a listener on the expected port to verify we're checking the right one
listener, err := net.Listen("tcp", "localhost:"+tc.expectedPort)
if err != nil {
t.Skipf("Cannot bind to port %s: %v", tc.expectedPort, err)
}
defer listener.Close()

// Perform health check
result := CheckServerHealth(targetURL, 1*time.Second)

// Should be healthy since we have a listener
if !result.Healthy {
t.Errorf("Expected server to be healthy on port %s, got unhealthy: %v", tc.expectedPort, result.Error)
}
})
}
}

func TestFormatHealthMessage_Healthy(t *testing.T) {
targetURL, _ := url.Parse("http://localhost:3000")
result := HealthCheckResult{
Status: HealthHealthy,
Healthy: true,
}

msg := FormatHealthMessage(result, targetURL)

if len(msg) == 0 {
t.Errorf("Expected non-empty message")
}
if !strings.Contains(msg, "→") {
t.Errorf("Expected message to contain →")
}
if !strings.Contains(msg, "Local server is reachable") {
t.Errorf("Expected message to contain 'Local server is reachable'")
}
}

func TestFormatHealthMessage_Unhealthy(t *testing.T) {
targetURL, _ := url.Parse("http://localhost:3000")
result := HealthCheckResult{
Status: HealthUnreachable,
Healthy: false,
Error: net.ErrClosed,
}

msg := FormatHealthMessage(result, targetURL)

if len(msg) == 0 {
t.Errorf("Expected non-empty message")
}
// Should contain warning indicator
if !strings.Contains(msg, "●") {
t.Errorf("Expected message to contain ●")
}
if !strings.Contains(msg, "Warning") {
t.Errorf("Expected message to contain 'Warning'")
}
}

func TestFormatHealthMessage_NilError(t *testing.T) {
targetURL, _ := url.Parse("http://localhost:3000")
result := HealthCheckResult{
Status: HealthUnreachable,
Healthy: false,
Error: nil, // Nil error should not cause panic
}

msg := FormatHealthMessage(result, targetURL)

if len(msg) == 0 {
t.Errorf("Expected non-empty message")
}
if !strings.Contains(msg, "unknown error") {
t.Errorf("Expected message to contain 'unknown error' when error is nil")
}
}

func TestCheckServerHealth_PortInURL(t *testing.T) {
// Create a server on a non-standard port
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
defer listener.Close()

// Get the actual port assigned by the OS
addr := listener.Addr().(*net.TCPAddr)
targetURL, _ := url.Parse(fmt.Sprintf("http://localhost:%d/path", addr.Port))

// Perform health check
result := CheckServerHealth(targetURL, 3*time.Second)

// Verify that the health check succeeded
// This confirms that when a port is already in the URL, we don't append
// a default port (which would cause localhost:8080 to become localhost:8080:80)
if !result.Healthy {
t.Errorf("Expected healthy=true for server with port in URL, got false: %v", result.Error)
}
if result.Error != nil {
t.Errorf("Expected no error for server with port in URL, got: %v", result.Error)
}
}
26 changes: 26 additions & 0 deletions pkg/listen/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"os"
"regexp"
"strings"
"time"

"github.com/hookdeck/hookdeck-cli/pkg/config"
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
Expand Down Expand Up @@ -122,6 +123,31 @@ Specify a single destination to update the path. For example, pass a connection
return err
}

// Perform initial health check on target server
// Using 3-second timeout optimized for local development scenarios.
// This assumes low latency to localhost. For production/edge deployments,
// this timeout may need to be configurable in future iterations.
healthCheckTimeout := 3 * time.Second
healthResult := CheckServerHealth(URL, healthCheckTimeout)

// For all output modes, warn if server isn't reachable
if !healthResult.Healthy {
warningMsg := FormatHealthMessage(healthResult, URL)

if flags.Output == "interactive" {
// Interactive mode will show warning before TUI starts
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
time.Sleep(500 * time.Millisecond) // Give user time to see warning before TUI starts
} else {
// Compact/quiet modes: print warning before connection info
fmt.Println()
fmt.Println(warningMsg)
fmt.Println()
}
}

// Start proxy
// For non-interactive modes, print connection info before starting
if flags.Output == "compact" || flags.Output == "quiet" {
Expand Down
Loading