feat: SSEMarshaler for browser-consumable streaming responses#92
feat: SSEMarshaler for browser-consumable streaming responses#92ankurs wants to merge 5 commits into
Conversation
SSEMarshaler implements runtime.Marshaler for text/event-stream so
server-streaming gateway RPCs can be consumed directly by browser
EventSource clients (e.g. AI/LLM token streams).
Services opt in from PreStart:
core.RegisterHTTPMarshaler("text/event-stream", &core.SSEMarshaler{})
The HTTP compression wrapper now excludes text/event-stream from
gzip/zstd via a wrapped DefaultContentTypeFilter — compressed SSE is
buffered by proxies/CDNs and defeats real-time delivery.
JSON payloads use the embedded runtime.JSONPb so field naming matches
the gateway's default responses. SSE is server-to-client only;
Unmarshal and NewDecoder return an error.
|
Warning Review limit reached
More reviews will be available in 54 minutes and 37 seconds. Learn how PR review limits work. Your organization has run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After more reviews become available, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available. Please see our Fair Usage Limits Policy for further information. 📝 WalkthroughWalkthroughAdds an SSE marshaler for gRPC-Gateway, centralizes mux option construction, adds a config flag to disable SSE auto-registration, excludes ChangesServer-Sent Events Marshaler and Compression Integration
Sequence Diagram(s)sequenceDiagram
participant Client
participant ServeMux
participant Config
participant SSEMarshaler
participant CompressionWrapper
Client->>ServeMux: HTTP request (Accept: text/event-stream)
ServeMux->>Config: read DisableSSEMarshaler
ServeMux->>SSEMarshaler: register/select marshaler (if enabled)
SSEMarshaler->>CompressionWrapper: produces text/event-stream responses
CompressionWrapper->>Client: bypass compression for text/event-stream
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds a new core.SSEMarshaler for browser-consumable text/event-stream responses from grpc-gateway server-streaming RPCs, and updates HTTP compression to avoid compressing SSE responses.
Changes:
- Introduces
SSEMarshalerwith SSE framing, content type, delimiter, encoder, and read-rejection behavior. - Excludes
text/event-streamresponses from gzhttp compression. - Adds tests and generated README documentation for the new marshaler and compression behavior.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents the new SSEMarshaler public API and usage. |
| marshaler_sse.go | Adds the SSE marshaler implementation. |
| marshaler_sse_test.go | Adds unit tests for SSE marshaling, encoding, delimiter, content type, and interface conformance. |
| compression.go | Adds SSE content-type exclusion to the HTTP compression wrapper. |
| compression_test.go | Adds coverage for ensuring SSE responses are not compressed. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
|
||
| // Delimiter returns "\n\n", which terminates one SSE frame. | ||
| func (*SSEMarshaler) Delimiter() []byte { | ||
| return sseDelimiter |
There was a problem hiding this comment.
Fixed in 5d9cc5e: Delimiter() now returns []byte("\n\n") freshly allocated per call, so callers cannot mutate framing for other SSEMarshaler instances. New test TestSSEMarshaler_DelimiterReturnsFreshSlice mutates the returned slice and verifies the next call still returns \n\n.
| out := make([]byte, 0, len(ssePrefix)+len(body)) | ||
| out = append(out, ssePrefix...) | ||
| out = append(out, body...) |
There was a problem hiding this comment.
Fixed in 5d9cc5e — same change as the CodeRabbit comment above: Marshal now prefixes every line of the payload, not just the first, so multiline JSON (when JSONPb is configured with Multiline/Indent) stays SSE-spec-compliant. Default single-line JSON is unaffected.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@marshaler_sse.go`:
- Around line 62-70: The Marshal implementation of SSEMarshaler currently
prepends ssePrefix only once and then appends the raw JSON body from
JSONPb.Marshal, which breaks multiline SSE payloads; change Marshal (method
SSEMarshaler.Marshal) to transform the marshaled body so every line is prefixed
with ssePrefix (for example, prepend ssePrefix to the whole body and replace
every '\n' in body with '\n'+ssePrefix) before returning, ensuring multiline
JSON lines each start with ssePrefix.
In `@README.md`:
- Around line 526-528: The README's fenced code blocks around examples 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 39d6f570-8046-438a-b8ca-c10d7247ad99
📒 Files selected for processing (5)
README.mdcompression.gocompression_test.gomarshaler_sse.gomarshaler_sse_test.go
| ``` | ||
| core.RegisterHTTPMarshaler("text/event-stream", &core.SSEMarshaler{}) | ||
| ``` |
There was a problem hiding this comment.
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.RegisterHTTPMarshaler("text/event-stream", &core.SSEMarshaler{})- +go
&core.SSEMarshaler{JSONPb: runtime.JSONPb{
MarshalOptions: protojson.MarshalOptions{EmitUnpopulated: true},
}}
</details>
Also applies to: 536-540
<details>
<summary>🧰 Tools</summary>
<details>
<summary>🪛 markdownlint-cli2 (0.22.1)</summary>
[warning] 526-526: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
</details>
</details>
<details>
<summary>🤖 Prompt for AI Agents</summary>
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.md around lines 526 - 528, The README's fenced code blocks around
examples 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.
</details>
<!-- fingerprinting:phantom:triton:hawk -->
<!-- This is an auto-generated reply by CodeRabbit -->
There was a problem hiding this comment.
Skipping for this PR. The README is auto-generated by gomarkdoc from 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 next make doc).
There was a problem hiding this comment.
@ankurs, understood — makes complete sense. Patching a generated file would just create noise for the next make doc run, and the fix belongs in the gomarkdoc template/tooling rather than here.
✏️ Learnings added
Learnt from: ankurs
Repo: go-coldbrew/core PR: 92
File: README.md:526-528
Timestamp: 2026-05-28T16:52:53.133Z
Learning: In the go-coldbrew/core repository, `README.md` (and `config/README.md`) are auto-generated files produced by `gomarkdoc` from Go doc comments via `make doc`. They should **not** be hand-edited, as any manual changes will be clobbered on the next `make doc` invocation. Issues with the generated output (e.g., missing language tags on fenced code blocks / MD040 warnings) need to be fixed upstream in the gomarkdoc configuration or Go doc comment formatting, not directly in the README.
Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.
Server-streaming gateway RPCs are now SSE-consumable out of the box for clients sending Accept: text/event-stream. The marshaler is auto-registered in initHTTP alongside the existing application/proto and JSON builtin options, matching the established "batteries included with Disable* opt-out" pattern (DISABLE_HTTP_COMPRESSION, DISABLE_NEW_RELIC, DISABLE_ZSTD_COMPRESSION). Set DISABLE_SSE_MARSHALER=true to suppress the registration — useful for services that want to register a custom SSE marshaler via RegisterHTTPMarshaler. Service-registered marshalers still win on the same MIME (last-write-wins inside grpc-gateway), so a custom variant can replace the default without setting the disable flag. Extracted the mux option assembly into buildHTTPMuxOptions so the toggle behavior can be tested directly via runtime.MarshalerForRequest without standing up the full HTTP server.
Marshal now prefixes every line of the JSON payload with "data: ", not just the first. Without this, callers who opt into protojson's multiline mode via the embedded JSONPb (MarshalOptions.Multiline/Indent) produce frames where only the first line carries the SSE prefix, causing EventSource clients to silently truncate the frame after the first newline. Single-line JSON (the default) is unchanged. Delimiter returns a freshly allocated slice per call so callers cannot mutate the framing for other SSEMarshaler instances in the process. Tests cover multiline frame prefixing and delimiter immutability.
|
Actionable comments posted: 0 |
The lint target was failing because govulncheck -scan=module flags any known CVE against any version of any module in go.mod, including stdlib toolchain advisories that aren't reachable from this library's code path. Switching to the default symbol scan only reports vulnerabilities that the call graph actually reaches. Also bumps the golang.org/x/* indirects to clear the real (non-stdlib) finding for golang.org/x/net's HTTP/2 SETTINGS_MAX_FRAME_SIZE infinite loop (GO-2026-4918). `go get -u golang.org/x/net && go mod tidy` brings in: x/net v0.52.0 -> v0.55.0 (fixes GO-2026-4918) x/crypto v0.49.0 -> v0.51.0 x/mod v0.34.0 -> v0.35.0 x/sys v0.42.0 -> v0.45.0 x/term v0.41.0 -> v0.43.0 x/text v0.35.0 -> v0.37.0 x/tools v0.43.0 -> v0.44.0 x/telemetry transitive bump After both changes, `make lint` (golangci-lint + govulncheck) is clean.
The symbol-scan govulncheck flagged four reachable stdlib vulnerabilities in 1.26.2 — html/template XSS, html/template escaper bypass, net Dial NUL-byte panic on Windows, and HTTP/2 SETTINGS_MAX_FRAME_SIZE infinite loop — all fixed in 1.26.3. CI's setup-go action reads go-version-file: go.mod, so bumping the directive here is what makes CI compile against the patched toolchain.
Summary
core.SSEMarshaler— aruntime.Marshalerthat emitstext/event-streamframes so server-streaming gateway RPCs are directly consumable by browserEventSourceclients (AI/LLM token streams, progressive loading, etc.).Disable*opt-out" pattern (DISABLE_HTTP_COMPRESSION,DISABLE_NEW_RELIC,DISABLE_ZSTD_COMPRESSION). SetDISABLE_SSE_MARSHALER=trueto suppress.text/event-streamfrom the gzhttp compression wrapper — compressed SSE is buffered by proxies/CDNs and breaks real-time delivery.Out of the box, any server-streaming gateway RPC responds with SSE framing when the client sends
Accept: text/event-stream, and with newline-delimited JSON otherwise.Design notes
runtime.JSONPbso JSON payload formatting matches the gateway's default JSON responses (field names, enum encoding, etc.).runtime.Delimited(\n\nbetween frames) andruntime.StreamContentType(returnstext/event-streamfor streaming responses).UnmarshalandNewDecoderreturn a sentinel error rather than silently accepting bogus input.registeredServeMuxOptions()), so a service can still overridetext/event-streamwith a custom marshaler viaRegisterHTTPMarshalerwithout setting the disable flag. Last-write-wins on the same MIME inside grpc-gateway.gzhttp.DefaultContentTypeFilterrather than replacing it, preserving existing behavior for non-SSE responses.buildHTTPMuxOptionsso the toggle is testable directly viaruntime.MarshalerForRequestwithout standing up the full HTTP server.Behavior change for existing services
On upgrade, existing services start serving
text/event-streamfor any server-streaming RPC when clients request it. Low risk (only triggers on content-type negotiation, no new endpoints exposed). Services that want the previous behavior setDISABLE_SSE_MARSHALER=true.Test plan
make test— full core suite green (covers SSE marshal/encoder/delimiter/content-type, gateway interface assertions, encoder write-error propagation, compression exclusion with bare andcharset=utf-8SSE content types, and the new toggle test usingruntime.MarshalerForRequest).make lint— golangci-lint clean (pre-existing vulncheck flag forgolang.org/x/netv0.52.0 is unrelated to this change).make doc— both READMEs regenerated.coreis tagged.Summary by CodeRabbit
New Features
Improvements
Configuration
Tests
Documentation