Skip to content

Commit f08fbac

Browse files
committed
Add sse-preflight sample (SSE CORS preflight replay) (#213)
* chore: add sse-preflight sample Adds a minimal Go sample that reproduces an SSE+CORS replay edge case: - Records an OPTIONS CORS preflight to an SSE endpoint on :8047 - Replays the testcase with Keploy (http :8000, sse :8047) - On affected Keploy versions, the preflight can be routed to :8000 and return 404 Includes: - dual-port server (:8000 HTTP, :8047 SSE) - client to generate the preflight request - keploy config + recorded testcase under sse-preflight/keploy - root README entry Signed-off-by: Anju <anjupathak9810@gmail.com> * chore: remove keploy directory Signed-off-by: Anju <anjupathak9810@gmail.com> * fix(sse-preflight): fail fast on listener errors Signed-off-by: Anju <anjupathak9810@gmail.com> * fix(sse-preflight): stop SSE handler on disconnect Signed-off-by: Anju <anjupathak9810@gmail.com> * chore(sse-preflight): tighten README and client errors Signed-off-by: Anju <anjupathak9810@gmail.com> * chore(sse-preflight): make host override truly optional Signed-off-by: Anju <anjupathak9810@gmail.com> * fix(sse-preflight): JSON-encode SSE payload Signed-off-by: Anju <anjupathak9810@gmail.com> * ci: build Go modules with go build ./... Signed-off-by: Anju <anjupathak9810@gmail.com> --------- Signed-off-by: Anju <anjupathak9810@gmail.com>
1 parent 8862eeb commit f08fbac

6 files changed

Lines changed: 260 additions & 26 deletions

File tree

.github/workflows/build.yml

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -32,33 +32,12 @@ jobs:
3232
# Change to the project directory
3333
cd "$dir" || continue
3434
35-
# Build the project
36-
if ls *.go &>/dev/null; then
37-
# Go files in root — build normally
38-
if go build -o app; then
39-
echo "Successfully built $dir"
40-
else
41-
echo "Failed to build $dir" >&2
42-
exit 1
43-
fi
35+
# Build the project (supports modules with multiple commands under ./cmd/*)
36+
if go build ./...; then
37+
echo "Successfully built $dir"
4438
else
45-
# No Go files in root — build each subdirectory with a main package
46-
built=false
47-
for subdir in */; do
48-
if [ -f "${subdir}main.go" ]; then
49-
if go build -o "$(basename "$subdir")" "./$subdir"; then
50-
echo "Successfully built $dir$subdir"
51-
built=true
52-
else
53-
echo "Failed to build $dir$subdir" >&2
54-
exit 1
55-
fi
56-
fi
57-
done
58-
if [ "$built" = false ]; then
59-
echo "No buildable Go packages found in $dir" >&2
60-
exit 1
61-
fi
39+
echo "Failed to build $dir" >&2
40+
exit 1
6241
fi
6342
6443
# Return to the base directory

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ This repo contains the sample for [Keploy's](https://keploy.io)🐰 with Golang.
3535
15. [Users-Profile](https://github.com/keploy/samples-go/tree/main/users-profile)
3636
16. [HTTP-PokeAPI](https://github.com/keploy/samples-go/tree/main/http-pokeapi)
3737
17. [book-store-inventory (`gin + sqlite`) ](https://github.com/keploy/samples-go/tree/main/book-store-inventory)
38+
18. [SSE-Preflight](https://github.com/keploy/samples-go/tree/main/sse-preflight)
3839

3940

4041
## Community Support ❤️

sse-preflight/README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# sse-preflight (Keploy repro)
2+
3+
This is a tiny Go app meant to reproduce the Keploy replay failure where an `OPTIONS` CORS preflight to an **SSE endpoint** (recorded on port `8047`) is replayed against the **normal HTTP port** (`8000`) and returns `404`.
4+
5+
## What the app does
6+
7+
- `:8000` (normal HTTP): only serves `GET /health` (everything else is `404`)
8+
- `:8047` (SSE): serves:
9+
- `OPTIONS /subscribe/student/events``200` + CORS headers (no `text/event-stream` Content-Type)
10+
- `GET /subscribe/student/events``200` + `Content-Type: text/event-stream`
11+
12+
## Run the server (without Keploy)
13+
14+
```bash
15+
cd sse-preflight
16+
go run ./cmd/server
17+
```
18+
19+
In another terminal:
20+
21+
```bash
22+
go run ./cmd/client --url "http://localhost:8047/subscribe/student/events?doubtId=repro" --host "doubt-service.example.com"
23+
```
24+
25+
You should see `status=200 OK`.
26+
27+
## Record + replay with Keploy (to reproduce the failure)
28+
29+
Prereq: make sure Keploy Enterprise auth is configured (for example `KEPLOY_API_KEY`), otherwise `keploy record/test` will prompt for an API key.
30+
31+
### 1) Record
32+
33+
Terminal A:
34+
35+
```bash
36+
keploy record -c "go run ./cmd/server"
37+
```
38+
39+
Terminal B (during the 20s window):
40+
41+
```bash
42+
go run ./cmd/client --url "http://localhost:8047/subscribe/student/events?doubtId=repro" --host "doubt-service.example.com"
43+
```
44+
45+
### 2) Replay
46+
47+
```bash
48+
keploy test -c "go run ./cmd/server"
49+
```
50+
51+
Expected: the preflight testcase fails with `EXPECT 200` vs `ACTUAL 404`, and in debug logs you should see the key part:
52+
53+
- `Overriding port with app_port` (8047)
54+
- `Config port overrides recorded app_port` (8000 overrides 8047)
55+
- `Final resolved target` uses `localhost:8000/...`

sse-preflight/cmd/client/main.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"os"
9+
"time"
10+
)
11+
12+
func main() {
13+
targetURL := flag.String("url", "http://localhost:8047/subscribe/student/events?doubtId=repro", "URL to send the CORS preflight to")
14+
hostHeader := flag.String("host", "", "Host header override (optional)")
15+
origin := flag.String("origin", "https://web.example.com", "Origin header")
16+
flag.Parse()
17+
18+
req, err := http.NewRequest(http.MethodOptions, *targetURL, nil)
19+
if err != nil {
20+
fmt.Fprintf(os.Stderr, "failed to create request: %v\n", err)
21+
os.Exit(1)
22+
}
23+
24+
if *hostHeader != "" {
25+
req.Host = *hostHeader
26+
}
27+
28+
req.Header.Set("Accept", "*/*")
29+
req.Header.Set("Origin", *origin)
30+
req.Header.Set("Access-Control-Request-Method", "GET")
31+
req.Header.Set("Access-Control-Request-Headers", "authorization,content-type,x-client-type,x-device-id,x-source")
32+
33+
client := &http.Client{Timeout: 5 * time.Second}
34+
resp, err := client.Do(req)
35+
if err != nil {
36+
fmt.Fprintf(os.Stderr, "request failed: %v\n", err)
37+
os.Exit(1)
38+
}
39+
defer resp.Body.Close()
40+
41+
body, err := io.ReadAll(resp.Body)
42+
if err != nil {
43+
fmt.Fprintf(os.Stderr, "failed to read response body: %v\n", err)
44+
os.Exit(1)
45+
}
46+
fmt.Printf("status=%s\n", resp.Status)
47+
fmt.Printf("headers=%v\n", resp.Header)
48+
fmt.Printf("body=%q\n", string(body))
49+
}

sse-preflight/cmd/server/main.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"flag"
7+
"fmt"
8+
"log"
9+
"net/http"
10+
"os"
11+
"os/signal"
12+
"syscall"
13+
"time"
14+
)
15+
16+
func main() {
17+
os.Exit(run())
18+
}
19+
20+
func run() int {
21+
httpPort := flag.Int("http-port", 8000, "normal HTTP port (non-SSE)")
22+
ssePort := flag.Int("sse-port", 8047, "SSE port")
23+
flag.Parse()
24+
25+
httpMux := http.NewServeMux()
26+
httpMux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
27+
w.WriteHeader(http.StatusOK)
28+
_, _ = w.Write([]byte("ok\n"))
29+
})
30+
// Intentionally do NOT register /subscribe/student/events (or "/") on :8000.
31+
// This allows us to reproduce the 404 when Keploy replays the SSE preflight on the wrong port.
32+
33+
sseMux := http.NewServeMux()
34+
sseMux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
35+
w.WriteHeader(http.StatusOK)
36+
_, _ = w.Write([]byte("ok\n"))
37+
})
38+
sseMux.HandleFunc("/subscribe/student/events", handleEvents)
39+
40+
httpSrv := &http.Server{Addr: fmt.Sprintf("0.0.0.0:%d", *httpPort), Handler: httpMux}
41+
sseSrv := &http.Server{Addr: fmt.Sprintf("0.0.0.0:%d", *ssePort), Handler: sseMux}
42+
43+
type serverErr struct {
44+
name string
45+
addr string
46+
err error
47+
}
48+
49+
exitCode := 0
50+
errCh := make(chan serverErr, 2)
51+
go func() {
52+
log.Printf("HTTP listening on %s", httpSrv.Addr)
53+
errCh <- serverErr{name: "HTTP", addr: httpSrv.Addr, err: httpSrv.ListenAndServe()}
54+
}()
55+
go func() {
56+
log.Printf("SSE listening on %s", sseSrv.Addr)
57+
errCh <- serverErr{name: "SSE", addr: sseSrv.Addr, err: sseSrv.ListenAndServe()}
58+
}()
59+
60+
stop := make(chan os.Signal, 1)
61+
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
62+
63+
select {
64+
case sig := <-stop:
65+
log.Printf("signal received: %s", sig)
66+
case server := <-errCh:
67+
if server.err != nil && server.err != http.ErrServerClosed {
68+
log.Printf("%s listener error on %s: %v", server.name, server.addr, server.err)
69+
log.Printf("hint: check for port conflicts/permissions, then retry")
70+
exitCode = 1
71+
}
72+
}
73+
74+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
75+
defer cancel()
76+
_ = httpSrv.Shutdown(ctx)
77+
_ = sseSrv.Shutdown(ctx)
78+
79+
return exitCode
80+
}
81+
82+
func writeCORS(w http.ResponseWriter) {
83+
w.Header().Set("Access-Control-Allow-Headers", "*")
84+
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
85+
w.Header().Set("Access-Control-Allow-Origin", "*")
86+
w.Header().Set("Access-Control-Max-Age", "7200")
87+
}
88+
89+
func handleEvents(w http.ResponseWriter, r *http.Request) {
90+
writeCORS(w)
91+
92+
// CORS preflight: respond successfully, but do NOT set text/event-stream.
93+
// This is key to reproducing the Keploy issue: the test case won't be detected as SSE.
94+
if r.Method == http.MethodOptions {
95+
w.Header().Set("Content-Length", "0")
96+
w.WriteHeader(http.StatusOK)
97+
return
98+
}
99+
100+
if r.Method != http.MethodGet {
101+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
102+
return
103+
}
104+
105+
flusher, ok := w.(http.Flusher)
106+
if !ok {
107+
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
108+
return
109+
}
110+
111+
w.Header().Set("Content-Type", "text/event-stream")
112+
w.Header().Set("Cache-Control", "no-cache")
113+
w.Header().Set("Connection", "keep-alive")
114+
w.WriteHeader(http.StatusOK)
115+
116+
doubtID := r.URL.Query().Get("doubtId")
117+
if doubtID == "" {
118+
doubtID = "missing"
119+
}
120+
121+
ctx := r.Context()
122+
for i := 0; i < 3; i++ {
123+
select {
124+
case <-ctx.Done():
125+
return
126+
default:
127+
}
128+
129+
payload, err := json.Marshal(map[string]any{"doubtId": doubtID, "n": i})
130+
if err != nil {
131+
return
132+
}
133+
134+
if _, err := fmt.Fprintf(w, "event: message\ndata: %s\n\n", payload); err != nil {
135+
return
136+
}
137+
flusher.Flush()
138+
139+
if i < 2 {
140+
select {
141+
case <-ctx.Done():
142+
return
143+
case <-time.After(250 * time.Millisecond):
144+
}
145+
}
146+
}
147+
}

sse-preflight/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module sse-preflight
2+
3+
go 1.23.0

0 commit comments

Comments
 (0)