Skip to content
Merged

v1.5 #189

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
494189f
chore(deps): bump github.com/spf13/cobra from 1.10.1 to 1.10.2
dependabot[bot] Dec 4, 2025
6da9e89
chore(deps): bump github.com/BurntSushi/toml from 1.5.0 to 1.6.0
dependabot[bot] Dec 19, 2025
613faa4
feat(listen): add TCP-based local server health checks
leggetter Jan 6, 2026
1d05a10
chore: Fix PR #186 feedback
leggetter Jan 6, 2026
dfea4ee
chore: use "events" and not "webhooks" in message
leggetter Jan 6, 2026
fbe7f5a
refactor(listen): fix remaining PR feedback issues
leggetter Jan 6, 2026
7e0453c
refactor(listen): remove unused lastHealthCheck field
leggetter Jan 6, 2026
45b0212
Move server health status from status bar to connection header in TUI
leggetter Jan 6, 2026
9a21f87
Improve server health warning visibility and styling
leggetter Jan 6, 2026
e841e0a
chore: Fix warning capitalization for consistency
leggetter Jan 6, 2026
3e4996a
refactor(listen): update output mode description and enhance connecti…
leggetter Jan 6, 2026
c8c33df
Fix ticker resource leak in health check monitor
leggetter Jan 7, 2026
2eefc89
refactor: address PR review feedback for healthcheck and proxy
leggetter Jan 8, 2026
81aec47
Merge pull request #186 from hookdeck/feat/listen-healthcheck
leggetter Jan 8, 2026
f27716b
fix: prevent status bar wrapping and add missing [q] Quit option
leggetter Jan 8, 2026
2efccaa
chore(deps): bump golang.org/x/sys from 0.38.0 to 0.40.0
dependabot[bot] Jan 8, 2026
82a333f
Merge pull request #187 from hookdeck/fix/dupe-quit-in-status-bar
leggetter Jan 8, 2026
91838d4
Merge pull request #185 from hookdeck/dependabot/go_modules/github.co…
leggetter Jan 8, 2026
bd50edd
Merge pull request #188 from hookdeck/dependabot/go_modules/golang.or…
leggetter Jan 8, 2026
ec3d540
Merge pull request #178 from hookdeck/dependabot/go_modules/github.co…
leggetter Jan 8, 2026
611fba6
chore(deps): bump golang.org/x/term from 0.37.0 to 0.38.0
dependabot[bot] Jan 8, 2026
c330a68
Merge pull request #180 from hookdeck/dependabot/go_modules/golang.or…
leggetter Jan 8, 2026
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
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ go 1.24.9

require (
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/BurntSushi/toml v1.5.0
github.com/BurntSushi/toml v1.6.0
github.com/briandowns/spinner v1.23.2
github.com/charmbracelet/bubbles v0.21.0
github.com/charmbracelet/bubbletea v1.3.10
Expand All @@ -16,14 +16,14 @@ require (
github.com/logrusorgru/aurora v2.0.3+incompatible
github.com/mitchellh/go-homedir v1.1.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/pretty v1.2.1
github.com/x-cray/logrus-prefixed-formatter v0.5.2
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/sys v0.40.0
golang.org/x/term v0.38.0
)

require (
Expand Down
16 changes: 8 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
Expand Down Expand Up @@ -135,8 +135,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
Expand Down Expand Up @@ -193,12 +193,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
Expand Down
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)
}
}
Loading