Skip to content

feat: SSEMarshaler for browser-consumable streaming responses#92

Open
ankurs wants to merge 5 commits into
mainfrom
feat/sse-marshaler
Open

feat: SSEMarshaler for browser-consumable streaming responses#92
ankurs wants to merge 5 commits into
mainfrom
feat/sse-marshaler

Conversation

@ankurs

@ankurs ankurs commented May 17, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds core.SSEMarshaler — a runtime.Marshaler that emits text/event-stream frames so server-streaming gateway RPCs are directly consumable by browser EventSource clients (AI/LLM token streams, progressive loading, etc.).
  • Auto-registers the SSE marshaler by default, matching the existing "batteries included with Disable* opt-out" pattern (DISABLE_HTTP_COMPRESSION, DISABLE_NEW_RELIC, DISABLE_ZSTD_COMPRESSION). Set DISABLE_SSE_MARSHALER=true to suppress.
  • Excludes text/event-stream from 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

  • Marshaler embeds runtime.JSONPb so JSON payload formatting matches the gateway's default JSON responses (field names, enum encoding, etc.).
  • Implements runtime.Delimited (\n\n between frames) and runtime.StreamContentType (returns text/event-stream for streaming responses).
  • SSE is server-to-client only — Unmarshal and NewDecoder return a sentinel error rather than silently accepting bogus input.
  • The auto-registration is prepended to mux options (before registeredServeMuxOptions()), so a service can still override text/event-stream with a custom marshaler via RegisterHTTPMarshaler without setting the disable flag. Last-write-wins on the same MIME inside grpc-gateway.
  • Compression exclusion composes with gzhttp.DefaultContentTypeFilter rather than replacing it, preserving existing behavior for non-SSE responses.
  • Mux option assembly extracted into buildHTTPMuxOptions so the toggle is testable directly via runtime.MarshalerForRequest without standing up the full HTTP server.

Behavior change for existing services

On upgrade, existing services start serving text/event-stream for 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 set DISABLE_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 and charset=utf-8 SSE content types, and the new toggle test using runtime.MarshalerForRequest).
  • make lint — golangci-lint clean (pre-existing vulncheck flag for golang.org/x/net v0.52.0 is unrelated to this change).
  • make doc — both READMEs regenerated.
  • Manual e2e via cookiecutter once core is tagged.

Summary by CodeRabbit

  • New Features

    • Server-Sent Events (text/event-stream) support for server-streaming responses
  • Improvements

    • HTTP compression now excludes SSE responses to avoid corrupting event streams
    • Gateway wiring centralized for SSE marshaler registration (configurable)
  • Configuration

    • New option to disable automatic SSE marshaler registration
  • Tests

    • Expanded tests covering SSE framing, encoder behavior, registration, and compression
  • Documentation

    • API docs updated with SSE marshaler and config option details

Review Change Stack

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.
Copilot AI review requested due to automatic review settings May 17, 2026 15:13
@coderabbitai

coderabbitai Bot commented May 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@ankurs, we couldn't start this review because you've reached your PR review rate limit.

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 @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c48279f2-c147-4ee0-bdcf-650425a29780

📥 Commits

Reviewing files that changed from the base of the PR and between 1196262 and 122a63a.

📒 Files selected for processing (1)
  • go.mod
📝 Walkthrough

Walkthrough

Adds an SSE marshaler for gRPC-Gateway, centralizes mux option construction, adds a config flag to disable SSE auto-registration, excludes text/event-stream from HTTP compression, updates README/api anchors and config docs, and adds comprehensive tests plus minor Makefile/go.mod updates.

Changes

Server-Sent Events Marshaler and Compression Integration

Layer / File(s) Summary
Mux options & config wiring
marshaler_sse.go, core.go, config/config.go, config/README.md
Adds buildHTTPMuxOptions, centralizes mux option construction, and adds Config.DisableSSEMarshaler to control auto-registration of the SSE marshaler.
SSEMarshaler Type and Methods
marshaler_sse.go
Defines SSEMarshaler embedding runtime.JSONPb; implements ContentType/StreamContentType = text/event-stream, Marshal prefixes JSON lines with data: , Delimiter() = \n\n, NewEncoder writes framed SSE, NewDecoder/Unmarshal return read-not-supported errors.
HTTP Compression Exclusion for SSE
compression.go, compression_test.go
Adds sseMediaType and excludeSSEContentTypeFilter (uses mime.ParseMediaType) and configures newHTTPCompressionWrapper to bypass compression for SSE variants; test verifies plain and parameterized SSE remain uncompressed.
Documentation and README anchors
README.md, config/README.md
Adds API reference for core.SSEMarshaler, updates index and heading anchors for core and config generated docs, and documents DisableSSEMarshaler.
SSEMarshaler Tests and Gateway Selection Tests
marshaler_sse_test.go
Comprehensive unit tests covering content types, delimiter freshness, marshal formatting (including multiline continuation), encoder stream framing, decoder/unmarshal error behavior, interface conformance, write-error propagation, and mux selection behavior when SSE is enabled/disabled.
Build & deps
Makefile, go.mod
Change lint target to run go tool govulncheck ./... and bump several golang.org/x/* indirect dependency versions in go.mod.

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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • go-coldbrew/core#88: Related changes to compression wrapper and marshaler registration plumbing this PR extends.

Suggested reviewers

  • vestor
  • fajran
  • svetha-cvl

Poem

🐰 I hop through streams with data: in tow,
Lines prefixed neat so events gently flow,
Delimiters double—\n\n close each part,
Compression steps back to keep the stream smart,
A tiny marshaler, sent server-to-client, heart to heart.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately captures the main feature: adding SSEMarshaler for server-streaming responses consumable by browsers via EventSource. It is concise, specific, and directly reflects the primary change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/sse-marshaler

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 SSEMarshaler with SSE framing, content type, delimiter, encoder, and read-rejection behavior.
  • Excludes text/event-stream responses 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.

Comment thread marshaler_sse.go Outdated

// Delimiter returns "\n\n", which terminates one SSE frame.
func (*SSEMarshaler) Delimiter() []byte {
return sseDelimiter

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread marshaler_sse.go
Comment on lines +67 to +69
out := make([]byte, 0, len(ssePrefix)+len(body))
out = append(out, ssePrefix...)
out = append(out, body...)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 9c3fafe and f4069b6.

📒 Files selected for processing (5)
  • README.md
  • compression.go
  • compression_test.go
  • marshaler_sse.go
  • marshaler_sse_test.go

Comment thread marshaler_sse.go
Comment thread README.md
Comment on lines +526 to +528
```
core.RegisterHTTPMarshaler("text/event-stream", &core.SSEMarshaler{})
```

@coderabbitai coderabbitai Bot May 17, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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 -->

Copy link
Copy Markdown
Member Author

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 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).

Copy link
Copy Markdown

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 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.
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown

Actionable comments posted: 0

ankurs added 2 commits May 29, 2026 19:47
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants