From 2ddb5937775d508e1b67ef439b8fe574266644e4 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 10 Nov 2026 21:44:28 +0200 Subject: [PATCH 1/3] feat(server): run playground subscriptions over SSE on SSE-only servers The playground always subscribed over WebSocket, so a server registering only sse.New() had no working subscriptions in GraphiQL. Pick graphql-sse when the SSE transport is registered without the WebSocket one; WebSocket remains the default in every other case. --- docs/content/docs/guides/subscriptions.mdx | 4 +- server/server.go | 100 ++++++++++++++------- server/server_test.go | 56 +++++++++++- 3 files changed, 125 insertions(+), 35 deletions(-) diff --git a/docs/content/docs/guides/subscriptions.mdx b/docs/content/docs/guides/subscriptions.mdx index 29a3632..23efc65 100644 --- a/docs/content/docs/guides/subscriptions.mdx +++ b/docs/content/docs/guides/subscriptions.mdx @@ -164,7 +164,9 @@ after the walkthrough. ## 4. Try it from the playground -The bundled GraphiQL UI auto-detects subscriptions. Run: +The bundled GraphiQL UI auto-detects subscriptions. It subscribes over +WebSocket by default and switches to SSE when `sse.New()` is the only +subscription transport registered. Run: ```graphql subscription { diff --git a/server/server.go b/server/server.go index af2c813..d28f055 100644 --- a/server/server.go +++ b/server/server.go @@ -170,7 +170,7 @@ type Server struct { schemaValue *schema.Schema PlaygroundPath string GraphqlPath string // normalized; persisted from New - SubscriptionPath string // pathname passed to graphql-ws in playground (canonical subscription URL) + SubscriptionPath string // pathname the playground subscribes to (canonical subscription URL) schemaSDLPath string separateSubs bool // graphqlPath != subscriptionPath routing @@ -178,6 +178,10 @@ type Server struct { subChain []core.Transport // subscriptionPath; empty when combined endpoint handler http.Handler requestTimeout time.Duration + + // playgroundSSE makes the playground run subscriptions over SSE. It is + // set when an *sse.Transport is registered without a WebSocket one. + playgroundSSE bool } const faviconPath = "/favicon.ico" @@ -331,11 +335,29 @@ func New(config Config) (*Server, error) { mainChain: main, subChain: sub, requestTimeout: config.RequestTimeout, + playgroundSSE: playgroundUsesSSE(config.Transports), } srv.handler = http.HandlerFunc(srv.serveHTTP) return srv, nil } +// playgroundUsesSSE reports whether the playground should subscribe over SSE: +// only when the bundled SSE transport is registered and the bundled WebSocket +// transport is not. WebSocket stays the default so custom transports that +// speak graphql-ws keep working. +func playgroundUsesSSE(transports []core.Transport) bool { + hasSSE := false + for _, transport := range transports { + switch transport.(type) { + case *websocket.WebSocketTransport: + return false + case *sse.Transport: + hasSSE = true + } + } + return hasSSE +} + func applySSEServerLimits(transport core.Transport, config Config) { sseTransport, ok := transport.(*sse.Transport) if !ok { @@ -396,7 +418,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet && r.URL.Path == s.PlaygroundPath { - servePlayground(w, s.canonicalGraphQLPath(), s.canonicalWSPath()) + servePlayground(w, s.canonicalGraphQLPath(), s.canonicalWSPath(), s.playgroundSSE) return } @@ -446,18 +468,23 @@ func (s *Server) withRequestDeadline(r *http.Request) (*http.Request, context.Ca return r.WithContext(ctx), cancel } -func servePlayground(w http.ResponseWriter, httpPath string, wsPath string) { +func servePlayground(w http.ResponseWriter, httpPath string, subscriptionPath string, useSSE bool) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - if _, err := w.Write([]byte(playgroundHTML(httpPath, wsPath))); err != nil { + if _, err := w.Write([]byte(playgroundHTML(httpPath, subscriptionPath, useSSE))); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } -func playgroundHTML(httpPath string, wsPath string) string { +func playgroundHTML(httpPath string, subscriptionPath string, useSSE bool) string { + subscriptionProtocol := "ws" + if useSSE { + subscriptionProtocol = "sse" + } replacer := strings.NewReplacer( "{{HTTP_ENDPOINT}}", strconv.Quote(httpPath), - "{{WS_ENDPOINT}}", strconv.Quote(wsPath), + "{{SUBSCRIPTION_ENDPOINT}}", strconv.Quote(subscriptionPath), + "{{SUBSCRIPTION_PROTOCOL}}", strconv.Quote(subscriptionProtocol), ) return replacer.Replace(` @@ -492,6 +519,7 @@ func playgroundHTML(httpPath string, wsPath string) string { "@graphiql/react": "https://esm.sh/@graphiql/react@1.0.2?standalone&external=react,react-dom,graphql,@graphiql/toolkit,@emotion/is-prop-valid", "graphql": "https://esm.sh/graphql@16.13.2", "graphql-ws": "https://esm.sh/graphql-ws@6.0.6?external=graphql", + "graphql-sse": "https://esm.sh/graphql-sse@2.6.3?external=graphql", "@emotion/is-prop-valid": "data:text/javascript," } } @@ -502,37 +530,47 @@ func playgroundHTML(httpPath string, wsPath string) string { import { GraphiQL } from "graphiql"; import { createTransport } from "@graphiql/toolkit"; import { createClient as createWSClient } from "graphql-ws"; + import { createClient as createSSEClient } from "graphql-sse"; import "graphiql/setup-workers/esm.sh"; const httpEndpoint = {{HTTP_ENDPOINT}}; - const wsEndpoint = {{WS_ENDPOINT}}; + const subscriptionEndpoint = {{SUBSCRIPTION_ENDPOINT}}; + const subscriptionProtocol = {{SUBSCRIPTION_PROTOCOL}}; const url = new URL(httpEndpoint, window.location.href); - const subscriptionUrl = new URL(wsEndpoint, window.location.href); - subscriptionUrl.protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - - // graphql-ws rejects with a raw WebSocket Event when the connection - // fails, which GraphiQL would print as {"isTrusted": true}. Turn it - // into a readable error; GraphQL errors pass through untouched. - const wsClient = createWSClient({ url: subscriptionUrl.toString() }); - const describeWSFailure = (err) => { - if (!(err instanceof Event)) return err; - const detail = err.reason || (err.code ? "close code " + err.code : ""); - return new Error( - "The WebSocket connection to " + subscriptionUrl.toString() + " failed" + - (detail ? " (" + detail + ")" : "") + - ". Check that the server has a WebSocket transport configured." - ); - }; - const subscriptionClient = { - async *iterate(request) { - try { - yield* wsClient.iterate(request); - } catch (err) { - throw describeWSFailure(err); - } - }, + const subscriptionUrl = new URL(subscriptionEndpoint, window.location.href); + + const createSSESubscriptionClient = () => createSSEClient({ url: subscriptionUrl.toString() }); + + const createWSSubscriptionClient = () => { + const wsUrl = new URL(subscriptionUrl); + wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; + const wsClient = createWSClient({ url: wsUrl.toString() }); + // graphql-ws rejects with a raw WebSocket Event when the connection + // fails, which GraphiQL would print as {"isTrusted": true}. Turn it + // into a readable error; GraphQL errors pass through untouched. + const describeWSFailure = (err) => { + if (!(err instanceof Event)) return err; + const detail = err.reason || (err.code ? "close code " + err.code : ""); + return new Error( + "The WebSocket connection to " + wsUrl.toString() + " failed" + + (detail ? " (" + detail + ")" : "") + + ". Check that the server has a WebSocket transport configured." + ); + }; + return { + async *iterate(request) { + try { + yield* wsClient.iterate(request); + } catch (err) { + throw describeWSFailure(err); + } + }, + }; }; + const subscriptionClient = + subscriptionProtocol === "sse" ? createSSESubscriptionClient() : createWSSubscriptionClient(); + const transport = createTransport({ url: url.toString(), subscriptionClient: subscriptionClient, diff --git a/server/server_test.go b/server/server_test.go index 5a7f17c..eb11b9b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -52,8 +52,10 @@ func TestServeHTTPServesPlaygroundAtConfiguredPath(t *testing.T) { "GraphiQL", "https://esm.sh/graphiql@6.0.1", `const httpEndpoint = "/graphql";`, - `const wsEndpoint = "/graphql";`, - `createWSClient({ url: subscriptionUrl.toString() })`, + `const subscriptionEndpoint = "/graphql";`, + `const subscriptionProtocol = "ws";`, + `createWSClient({ url: wsUrl.toString() })`, + `createSSEClient({ url: subscriptionUrl.toString() })`, `subscriptionClient: subscriptionClient`, `Check that the server has a WebSocket transport configured.`, `transport: transport`, @@ -65,6 +67,54 @@ func TestServeHTTPServesPlaygroundAtConfiguredPath(t *testing.T) { } } +func TestPlaygroundSubscriptionProtocolFollowsRegisteredTransports(t *testing.T) { + cases := []struct { + name string + transports []core.Transport + want string + }{ + {name: "no subscription transport", transports: nil, want: "ws"}, + {name: "websocket only", transports: []core.Transport{websocket.New()}, want: "ws"}, + {name: "sse only", transports: []core.Transport{sse.New()}, want: "sse"}, + {name: "websocket and sse", transports: []core.Transport{websocket.New(), sse.New()}, want: "ws"}, + {name: "sse listed first", transports: []core.Transport{sse.New(), websocket.New()}, want: "ws"}, + {name: "custom transport", transports: []core.Transport{customHTTPTransport{}}, want: "ws"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := playgroundHTML("/graphql", "/graphql", playgroundUsesSSE(tc.transports)) + want := `const subscriptionProtocol = "` + tc.want + `";` + if !strings.Contains(body, want) { + t.Fatalf("expected playground HTML to contain %q", want) + } + }) + } +} + +func TestNewServesSSEPlaygroundForSSEOnlyServer(t *testing.T) { + server, err := New(Config{ + Schema: subscriptiongraph.Schema(newSubscriptionTestBus(t)), + PlaygroundPath: "/playground", + SubscriptionPath: "/events", + Transports: []core.Transport{sse.New()}, + }) + if err != nil { + t.Fatalf("new server: %v", err) + } + response := httptest.NewRecorder() + server.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/playground", nil)) + + body := response.Body.String() + for _, expectedValue := range []string{ + `const subscriptionEndpoint = "/events";`, + `const subscriptionProtocol = "sse";`, + } { + if !strings.Contains(body, expectedValue) { + t.Fatalf("expected playground HTML to contain %q", expectedValue) + } + } +} + func TestServeHTTPDoesNotServePlaygroundAtGraphQLPathWhenConfiguredElsewhere(t *testing.T) { server := Server{PlaygroundPath: "/playground"} request := httptest.NewRequest(http.MethodGet, "/graphql", nil) @@ -868,7 +918,7 @@ func (w *failingBodyWriter) Write([]byte) (int, error) { func TestServePlaygroundReturns500WhenWriteFails(t *testing.T) { w := &failingBodyWriter{} - servePlayground(w, "/graphql", "/graphql") + servePlayground(w, "/graphql", "/graphql", false) if w.code != http.StatusInternalServerError { t.Fatalf("status = %d", w.code) } From f13433ce3df4e25e402f3a17eeb3f3f4a6313a06 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 10 Nov 2026 21:59:25 +0200 Subject: [PATCH 2/3] docs(examples): add an SSE-only subscriptions example examples/sse registers sse.New() as the only subscription transport and shows a self-terminating countdown stream plus pub/sub notifications. --- README.md | 1 + docs/content/docs/examples/index.mdx | 39 ++++++++++ examples/sse/graph/countdown.go | 59 +++++++++++++++ examples/sse/graph/notification.go | 104 +++++++++++++++++++++++++++ examples/sse/graph/schema.go | 36 ++++++++++ examples/sse/graph/schema_test.go | 100 ++++++++++++++++++++++++++ examples/sse/main.go | 35 +++++++++ 7 files changed, 374 insertions(+) create mode 100644 examples/sse/graph/countdown.go create mode 100644 examples/sse/graph/notification.go create mode 100644 examples/sse/graph/schema.go create mode 100644 examples/sse/graph/schema_test.go create mode 100644 examples/sse/main.go diff --git a/README.md b/README.md index 1cc303a..ef50ddb 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ Runnable examples live in [`examples/`](./examples): ```bash go run ./examples/basic # queries, mutations, plugins go run ./examples/subscriptions # pub/sub over WebSocket and SSE +go run ./examples/sse # SSE-only subscriptions, no WebSocket go run ./examples/auth # bearer auth, context identity, field authorizer ``` diff --git a/docs/content/docs/examples/index.mdx b/docs/content/docs/examples/index.mdx index ddaa7b9..d3471a3 100644 --- a/docs/content/docs/examples/index.mdx +++ b/docs/content/docs/examples/index.mdx @@ -329,6 +329,45 @@ go run ./examples/subscriptions --- +### Subscriptions over SSE only [#examples-sse] + +Registers `sse.New()` as the only subscription transport, so subscriptions are ordinary HTTP requests answered with a `text/event-stream` response - no WebSocket upgrade. The bundled playground detects this and subscribes over SSE too. + +#### `examples/sse/main.go` + +../examples/sse/main.go + +`countdown` produces its own stream and closes it when it reaches zero, which ends the response with a `complete` event. `notifications` relays events published by `sendNotification` through the in-memory bus. + +#### `examples/sse/graph/schema.go` + +../examples/sse/graph/schema.go + +#### `examples/sse/graph/countdown.go` + +../examples/sse/graph/countdown.go + +#### `examples/sse/graph/notification.go` + +../examples/sse/graph/notification.go + +```bash +go run ./examples/sse +``` + +Subscribe from a terminal: + +```bash +curl -N http://localhost:4000/graphql \ + -H 'Content-Type: application/json' \ + -H 'Accept: text/event-stream' \ + -d '{"query":"subscription { countdown(from: 3) { remaining } }"}' +``` + +**Dig deeper:** [**Realtime subscriptions**](/guides/subscriptions) + +--- + ## Router integrations [#examples-router-integrations] `grx` answers **`ServeHTTP`** with exact path matches - you usually pair **`http.StripPrefix("/api", gql)`** with **`WithPlaygroundPath("/playground")`** and **`WithGraphQLPath("/query")`** so callers see **`/api/playground`** and **`POST /api/query`**. diff --git a/examples/sse/graph/countdown.go b/examples/sse/graph/countdown.go new file mode 100644 index 0000000..fc785f9 --- /dev/null +++ b/examples/sse/graph/countdown.go @@ -0,0 +1,59 @@ +package graph + +import ( + "context" + "fmt" + "time" +) + +// maxCountdown bounds how long a single countdown stream can stay open. +const maxCountdown = 60 + +type Countdown struct { + Remaining int `gql:"remaining,nonNull"` +} + +type CountdownArgs struct { + From int `gql:"from,nonNull"` +} + +// CountdownSubscription needs no pub/sub: the resolver produces the stream +// itself and closes the channel when it is done, which ends the SSE response +// with a "complete" event. +type CountdownSubscription struct { + // Interval is the delay between ticks. Zero means one second. + Interval time.Duration +} + +func (s CountdownSubscription) Countdown(ctx context.Context, args CountdownArgs) (<-chan *Countdown, error) { + if args.From < 0 || args.From > maxCountdown { + return nil, fmt.Errorf("from must be between 0 and %d", maxCountdown) + } + interval := s.Interval + if interval <= 0 { + interval = time.Second + } + + out := make(chan *Countdown) + go func() { + defer close(out) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for remaining := args.From; remaining >= 0; remaining-- { + select { + case <-ctx.Done(): + return + case out <- &Countdown{Remaining: remaining}: + } + if remaining == 0 { + return + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return out, nil +} diff --git a/examples/sse/graph/notification.go b/examples/sse/graph/notification.go new file mode 100644 index 0000000..524abf3 --- /dev/null +++ b/examples/sse/graph/notification.go @@ -0,0 +1,104 @@ +package graph + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/grx-gql/grx/pubsub" +) + +// notificationTopicPrefix is the bus topic prefix for notification events. +// Each channel gets its own topic so subscribers only receive its traffic. +const notificationTopicPrefix = "notification.sent." + +func notificationTopic(channel string) string { + return notificationTopicPrefix + channel +} + +type Notification struct { + ID string `gql:"id,nonNull"` + Channel string `gql:"channel,nonNull"` + Text string `gql:"text,nonNull"` + SentAt string `gql:"sentAt,nonNull"` +} + +type SendNotificationInput struct { + Channel string `gql:"channel,nonNull"` + Text string `gql:"text,nonNull"` +} + +type SendNotificationArgs struct { + Input SendNotificationInput `gql:"input,nonNull"` +} + +type NotificationsArgs struct { + Channel string `gql:"channel,nonNull"` +} + +// NotificationMutation publishes each notification on its channel's topic. +type NotificationMutation struct { + Bus pubsub.PubSub + nextID atomic.Uint64 +} + +func (m *NotificationMutation) SendNotification(ctx context.Context, args SendNotificationArgs) (*Notification, error) { + channel := strings.TrimSpace(args.Input.Channel) + if channel == "" { + return nil, fmt.Errorf("channel is required") + } + if strings.TrimSpace(args.Input.Text) == "" { + return nil, fmt.Errorf("text is required") + } + + notification := &Notification{ + ID: fmt.Sprintf("notification_%d", m.nextID.Add(1)), + Channel: channel, + Text: args.Input.Text, + SentAt: time.Now().UTC().Format(time.RFC3339Nano), + } + payload, err := json.Marshal(notification) + if err != nil { + return nil, err + } + if err := m.Bus.Publish(ctx, notificationTopic(channel), payload); err != nil { + return nil, err + } + return notification, nil +} + +// NotificationSubscription relays the notifications published on a channel +// to every client subscribed to it. +type NotificationSubscription struct { + Bus pubsub.PubSub +} + +func (s NotificationSubscription) Notifications(ctx context.Context, args NotificationsArgs) (<-chan *Notification, error) { + channel := strings.TrimSpace(args.Channel) + if channel == "" { + return nil, fmt.Errorf("channel is required") + } + msgs, err := s.Bus.Subscribe(ctx, notificationTopic(channel)) + if err != nil { + return nil, err + } + out := make(chan *Notification) + go func() { + defer close(out) + for msg := range msgs { + var notification Notification + if err := json.Unmarshal(msg.Payload, ¬ification); err != nil { + continue + } + select { + case <-ctx.Done(): + return + case out <- ¬ification: + } + } + }() + return out, nil +} diff --git a/examples/sse/graph/schema.go b/examples/sse/graph/schema.go new file mode 100644 index 0000000..d0664d0 --- /dev/null +++ b/examples/sse/graph/schema.go @@ -0,0 +1,36 @@ +package graph + +import ( + "context" + "time" + + "github.com/grx-gql/grx/pubsub" + "github.com/grx-gql/grx/schema" +) + +// Query is the schema's root query type. +type Query struct{} + +// ServerTime returns the server's current UTC time. +func (Query) ServerTime(ctx context.Context) (string, error) { + return time.Now().UTC().Format(time.RFC3339), nil +} + +// Subscription is the schema's root subscription type. Countdown streams on +// its own; Notifications relays events published by SendNotification. +type Subscription struct { + CountdownSubscription + NotificationSubscription +} + +// Schema composes schema.Config. The bus carries notification events from +// the mutation to every active subscription. +func Schema(bus pubsub.PubSub) schema.Config { + return schema.Config{ + Query: Query{}, + Mutation: &NotificationMutation{Bus: bus}, + Subscription: Subscription{ + NotificationSubscription: NotificationSubscription{Bus: bus}, + }, + } +} diff --git a/examples/sse/graph/schema_test.go b/examples/sse/graph/schema_test.go new file mode 100644 index 0000000..15658b6 --- /dev/null +++ b/examples/sse/graph/schema_test.go @@ -0,0 +1,100 @@ +package graph_test + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/grx-gql/grx/core" + "github.com/grx-gql/grx/examples/sse/graph" + "github.com/grx-gql/grx/exec" + "github.com/grx-gql/grx/pubsub/memory" + "github.com/grx-gql/grx/schema" +) + +func newExecutor(t *testing.T) core.Executor { + t.Helper() + bus := memory.New() + t.Cleanup(func() { _ = bus.Close() }) + compiled, err := schema.Build(graph.Schema(bus)) + if err != nil { + t.Fatal(err) + } + return exec.New(compiled, nil) +} + +func TestNotificationsStreamSentNotifications(t *testing.T) { + executor := newExecutor(t) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + stream, err := executor.Subscribe(ctx, core.Request{Query: `subscription { notifications(channel: "ops") { text } }`}) + if err != nil { + t.Fatal(err) + } + response := executor.Execute(ctx, core.Request{Query: `mutation { sendNotification(input: {channel: "ops", text: "deploy done"}) { id } }`}) + if len(response.Errors) != 0 || response.Data == nil { + t.Fatalf("mutation: %+v", response) + } + + select { + case event, ok := <-stream: + if !ok || len(event.Errors) != 0 { + t.Fatalf("subscription closed or failed: %+v", event) + } + encoded, err := json.Marshal(event.Data) + if err != nil { + t.Fatal(err) + } + if want := `{"notifications":{"text":"deploy done"}}`; string(encoded) != want { + t.Fatalf("got %s, want %s", encoded, want) + } + case <-ctx.Done(): + t.Fatal("subscription did not receive the notification") + } +} + +func TestCountdownCountsToZeroThenCompletes(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + sub := graph.CountdownSubscription{Interval: time.Millisecond} + ticks, err := sub.Countdown(ctx, graph.CountdownArgs{From: 3}) + if err != nil { + t.Fatal(err) + } + var got []int + for tick := range ticks { + got = append(got, tick.Remaining) + } + if want := []int{3, 2, 1, 0}; len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } else { + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } + } +} + +func TestCountdownRejectsOutOfRangeStart(t *testing.T) { + for _, from := range []int{-1, 61} { + if _, err := (graph.CountdownSubscription{}).Countdown(context.Background(), graph.CountdownArgs{From: from}); err == nil { + t.Errorf("from=%d: expected an error", from) + } + } +} + +func TestSendNotificationRequiresChannelAndText(t *testing.T) { + executor := newExecutor(t) + for _, query := range []string{ + `mutation { sendNotification(input: {channel: " ", text: "x"}) { id } }`, + `mutation { sendNotification(input: {channel: "ops", text: " "}) { id } }`, + } { + if response := executor.Execute(context.Background(), core.Request{Query: query}); len(response.Errors) == 0 { + t.Errorf("%s: expected an error", query) + } + } +} diff --git a/examples/sse/main.go b/examples/sse/main.go new file mode 100644 index 0000000..2a4aedd --- /dev/null +++ b/examples/sse/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "log" + "net/http" + + "github.com/grx-gql/grx" + "github.com/grx-gql/grx/examples/sse/graph" + "github.com/grx-gql/grx/pubsub/memory" + "github.com/grx-gql/grx/sse" +) + +const port = "4000" + +func main() { + bus := memory.New() + defer bus.Close() + + // sse.New() is the only subscription transport, so subscriptions are + // plain HTTP requests answered with a text/event-stream response. The + // bundled playground detects this and subscribes over SSE as well. + srv, err := grx.NewServer( + grx.WithSchema(graph.Schema(bus)), + grx.WithPlaygroundPath("/"), + grx.WithTransports(sse.New()), + ) + if err != nil { + log.Fatal(err) + } + + log.Println("GraphQL playground: http://localhost:" + port + srv.PlaygroundPath) + log.Println("GraphQL endpoint: POST http://localhost:" + port + srv.GraphqlPath) + log.Println("Subscriptions (SSE): POST http://localhost:" + port + srv.SubscriptionPath + " with Accept: text/event-stream") + log.Fatal(http.ListenAndServe(":"+port, srv)) +} From efc8fa27a6e95ca48109fe13ee8e5e57da232522 Mon Sep 17 00:00:00 2001 From: Patrick Kabwe Date: Tue, 10 Nov 2026 22:00:47 +0200 Subject: [PATCH 3/3] refactor(server): derive the playground subscription protocol from registered transports Drop the playgroundSSE field and inspect the server's transport chains when the playground is served instead. --- server/server.go | 26 +++++++++++++------------- server/server_test.go | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/server/server.go b/server/server.go index d28f055..835fdee 100644 --- a/server/server.go +++ b/server/server.go @@ -178,10 +178,6 @@ type Server struct { subChain []core.Transport // subscriptionPath; empty when combined endpoint handler http.Handler requestTimeout time.Duration - - // playgroundSSE makes the playground run subscriptions over SSE. It is - // set when an *sse.Transport is registered without a WebSocket one. - playgroundSSE bool } const faviconPath = "/favicon.ico" @@ -335,7 +331,6 @@ func New(config Config) (*Server, error) { mainChain: main, subChain: sub, requestTimeout: config.RequestTimeout, - playgroundSSE: playgroundUsesSSE(config.Transports), } srv.handler = http.HandlerFunc(srv.serveHTTP) return srv, nil @@ -345,14 +340,19 @@ func New(config Config) (*Server, error) { // only when the bundled SSE transport is registered and the bundled WebSocket // transport is not. WebSocket stays the default so custom transports that // speak graphql-ws keep working. -func playgroundUsesSSE(transports []core.Transport) bool { +func (s *Server) playgroundUsesSSE() bool { hasSSE := false - for _, transport := range transports { - switch transport.(type) { - case *websocket.WebSocketTransport: - return false - case *sse.Transport: - hasSSE = true + for _, chain := range [][]core.Transport{s.mainChain, s.subChain} { + for _, transport := range chain { + if restricted, ok := transport.(pathRestricted); ok { + transport = restricted.Transport + } + switch transport.(type) { + case *websocket.WebSocketTransport: + return false + case *sse.Transport: + hasSSE = true + } } } return hasSSE @@ -418,7 +418,7 @@ func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { } if r.Method == http.MethodGet && r.URL.Path == s.PlaygroundPath { - servePlayground(w, s.canonicalGraphQLPath(), s.canonicalWSPath(), s.playgroundSSE) + servePlayground(w, s.canonicalGraphQLPath(), s.canonicalWSPath(), s.playgroundUsesSSE()) return } diff --git a/server/server_test.go b/server/server_test.go index eb11b9b..fe23b7b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -82,7 +82,7 @@ func TestPlaygroundSubscriptionProtocolFollowsRegisteredTransports(t *testing.T) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - body := playgroundHTML("/graphql", "/graphql", playgroundUsesSSE(tc.transports)) + body := playgroundHTML("/graphql", "/graphql", (&Server{mainChain: tc.transports}).playgroundUsesSSE()) want := `const subscriptionProtocol = "` + tc.want + `";` if !strings.Contains(body, want) { t.Fatalf("expected playground HTML to contain %q", want)