-
Notifications
You must be signed in to change notification settings - Fork 4
feat: SSEMarshaler for browser-consumable streaming responses #92
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
f4069b6
cf5fcc8
5d9cc5e
1196262
122a63a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,38 @@ | ||
| package core | ||
|
|
||
| import ( | ||
| "mime" | ||
| "net/http" | ||
|
|
||
| "github.com/go-coldbrew/core/config" | ||
| "github.com/klauspost/compress/gzhttp" | ||
| ) | ||
|
|
||
| // sseMediaType is the Content-Type advertised by SSEMarshaler and excluded | ||
| // from HTTP compression. | ||
| const sseMediaType = "text/event-stream" | ||
|
|
||
| // newHTTPCompressionWrapper builds the gzhttp wrapper used by initHTTP. It | ||
| // negotiates gzip and (unless disabled) zstd from Accept-Encoding. Pulled out | ||
| // so it can be tested without standing up the full gateway. | ||
| // | ||
| // text/event-stream is excluded via excludeSSEContentTypeFilter — proxies | ||
| // and CDNs buffer compressed SSE responses, defeating real-time delivery. | ||
| func newHTTPCompressionWrapper(cfg config.Config) (func(http.Handler) http.HandlerFunc, error) { | ||
| return gzhttp.NewWrapper( | ||
| gzhttp.MinSize(cfg.HTTPCompressionMinSize), | ||
| gzhttp.EnableZstd(!cfg.DisableZstdCompression), | ||
| gzhttp.PreferZstd(!cfg.DisableZstdCompression && cfg.PreferZstd), | ||
| gzhttp.ContentTypeFilter(excludeSSEContentTypeFilter), | ||
| ) | ||
| } | ||
|
|
||
| // excludeSSEContentTypeFilter wraps gzhttp.DefaultContentTypeFilter to also | ||
| // exclude text/event-stream, so SSE frames are delivered uncompressed and | ||
| // reach the client without intermediary buffering. | ||
| func excludeSSEContentTypeFilter(ct string) bool { | ||
| if mediaType, _, err := mime.ParseMediaType(ct); err == nil && mediaType == sseMediaType { | ||
| return false | ||
| } | ||
| return gzhttp.DefaultContentTypeFilter(ct) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| package core | ||
|
|
||
| import ( | ||
| "errors" | ||
| "io" | ||
|
|
||
| "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" | ||
| ) | ||
|
|
||
| // SSEMarshaler is a runtime.Marshaler that emits Server-Sent Events | ||
| // (text/event-stream) frames for server-streaming gateway RPCs. It lets | ||
| // browser EventSource clients consume streaming RPCs directly — useful for | ||
| // AI/LLM token streaming and other long-running progressive responses. | ||
| // | ||
| // Each Marshal call returns "data: <json>" with no trailing newline; the | ||
| // Delimiter ("\n\n") terminates each SSE frame per the SSE spec. The JSON | ||
| // payload uses protojson via the embedded runtime.JSONPb, so field naming | ||
| // matches the gateway's default JSON responses. | ||
| // | ||
| // Wire it up from a service's PreStart hook: | ||
| // | ||
| // core.RegisterHTTPMarshaler("text/event-stream", &core.SSEMarshaler{}) | ||
| // | ||
| // Clients then opt in by sending Accept: text/event-stream on the gateway | ||
| // URL. The newHTTPCompressionWrapper excludes text/event-stream from | ||
| // gzip/zstd compression so frames reach the client in real time (compressed | ||
| // SSE is buffered by many HTTP intermediaries). | ||
| // | ||
| // SSE is server-to-client only: Unmarshal and NewDecoder return an error. | ||
| // | ||
| // Per-field protojson options (EmitUnpopulated, UseProtoNames, etc.) can be | ||
| // set by initializing the embedded JSONPb directly: | ||
| // | ||
| // &core.SSEMarshaler{JSONPb: runtime.JSONPb{ | ||
| // MarshalOptions: protojson.MarshalOptions{EmitUnpopulated: true}, | ||
| // }} | ||
| type SSEMarshaler struct { | ||
| runtime.JSONPb | ||
| } | ||
|
|
||
| var ( | ||
| ssePrefix = []byte("data: ") | ||
| sseDelimiter = []byte("\n\n") | ||
| errSSEReadNotSupported = errors.New("core: SSEMarshaler does not support reading; Server-Sent Events is a server-to-client format") | ||
| ) | ||
|
|
||
| // ContentType always returns "text/event-stream". | ||
| func (*SSEMarshaler) ContentType(_ any) string { | ||
| return sseMediaType | ||
| } | ||
|
|
||
| // StreamContentType matches ContentType so server-streaming responses also | ||
| // advertise text/event-stream. Gateway prefers this over ContentType when | ||
| // implemented (see runtime.ForwardResponseStream). | ||
| func (*SSEMarshaler) StreamContentType(_ any) string { | ||
| return sseMediaType | ||
| } | ||
|
|
||
| // Marshal returns "data: <json>" with no trailing newline. Frame | ||
| // termination is supplied by Delimiter; the gateway writes Marshal output | ||
| // followed by Delimiter for each streamed message. | ||
| func (s *SSEMarshaler) Marshal(v any) ([]byte, error) { | ||
| body, err := s.JSONPb.Marshal(v) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| out := make([]byte, 0, len(ssePrefix)+len(body)) | ||
| out = append(out, ssePrefix...) | ||
| out = append(out, body...) | ||
|
Comment on lines
+103
to
+105
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5d9cc5e — same change as the CodeRabbit comment above: |
||
| return out, nil | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Delimiter returns "\n\n", which terminates one SSE frame. | ||
| func (*SSEMarshaler) Delimiter() []byte { | ||
| return sseDelimiter | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 5d9cc5e: |
||
| } | ||
|
|
||
| // Unmarshal returns an error: SSE is a server-to-client format and the | ||
| // gateway never reads SSE bodies from inbound requests. | ||
| func (*SSEMarshaler) Unmarshal(_ []byte, _ any) error { | ||
| return errSSEReadNotSupported | ||
| } | ||
|
|
||
| // NewDecoder returns a decoder that always errors, for the same reason as | ||
| // Unmarshal. | ||
| func (*SSEMarshaler) NewDecoder(_ io.Reader) runtime.Decoder { | ||
| return runtime.DecoderFunc(func(_ any) error { | ||
| return errSSEReadNotSupported | ||
| }) | ||
| } | ||
|
|
||
| // NewEncoder returns an encoder that writes "data: <json>\n\n" per Encode | ||
| // call. | ||
| func (s *SSEMarshaler) NewEncoder(w io.Writer) runtime.Encoder { | ||
| return runtime.EncoderFunc(func(v any) error { | ||
| body, err := s.Marshal(v) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if _, err := w.Write(body); err != nil { | ||
| return err | ||
| } | ||
| _, err = w.Write(s.Delimiter()) | ||
| return err | ||
| }) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add language identifiers to fenced code blocks.
The two new fenced blocks are missing language tags (
MD040) and can fail markdown lint.Suggested fix
-
+go&core.SSEMarshaler{JSONPb: runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{EmitUnpopulated: true},
}}
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
@README.mdaround lines 526 - 528, The README's fenced code blocks aroundexamples that include core.RegisterHTTPMarshaler, &core.SSEMarshaler{}, and the
&core.SSEMarshaler{JSONPb: runtime.JSONPb{...}} snippet lack language
identifiers and trigger MD040; update each of those triple-backtick blocks to
include the Go language tag (```go) so the examples for
core.RegisterHTTPMarshaler and the SSEMarshaler/JSONPb snippet are fenced as Go
code.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Skipping for this PR. The README is auto-generated by
gomarkdocfrom the Go doc comments — those code samples render without language tags because gomarkdoc reformats them. MD040 is a lint warning, not a CI failure, and the right fix is upstream in gomarkdoc rather than hand-editing the regenerated README (which would just get clobbered on the nextmake doc).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@ankurs, understood — makes complete sense. Patching a generated file would just create noise for the nextmake docrun, and the fix belongs in the gomarkdoc template/tooling rather than here.✏️ Learnings added