Skip to content

feat(gen): support SSE server response generation#1715

Open
Azer0s wants to merge 2 commits into
ogen-go:mainfrom
Azer0s:feat/sse-server-gen
Open

feat(gen): support SSE server response generation#1715
Azer0s wants to merge 2 commits into
ogen-go:mainfrom
Azer0s:feat/sse-server-gen

Conversation

@Azer0s

@Azer0s Azer0s commented Jul 22, 2026

Copy link
Copy Markdown

Generate the server side of text/event-stream responses, which previously raised ErrNotImplemented("sse server response encoding").

The generated response stream type gains an Events field, called by the server after the response headers are sent:

return &api.GetEventsOK{
    Events: func(ctx context.Context, s api.GetEventsOKSender) error {
        return s.Send(ctx, api.GetEventsOKEvent{Data: message})
    },
}, nil

Client and server sides of a stream type are now generated independently, so client-only output is unchanged and server-only output contains no stream reader.

  • add sse.Encoder, writing the event stream, and sse.Sender, encoding typed events for it;
  • encode typed events for data-only, full and full-array shapes, honoring server/response/validation. String data is written unquoted, so that the stream stays consumable by EventSource clients;
  • set Cache-Control: no-cache, flush the headers before the first event, and flush every written event;
  • report ht.ErrResponseSent when Events fails, so that the handler does not write an error response into the already sent stream.

@m-ali-akbay

m-ali-akbay commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Please consider using separate commits for generated files. 🙏

Generated code should be committed in a one separate commit `chore: commit generated files`.

@Azer0s
Azer0s force-pushed the feat/sse-server-gen branch from e59dd96 to e9ad4ef Compare July 22, 2026 12:35
@Azer0s

Azer0s commented Jul 22, 2026

Copy link
Copy Markdown
Author

Please consider using separate commits for generated files. 🙏

Generated code should be committed in a one separate commit `chore: commit generated files`.

Done :)

@zguydev

zguydev commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Original author of the SSE client generation feature here.

While working on SSE support, I came to the conclusion that it is much more about getting the behavior right than just adding code generation on top of it. A lot of the decisions here affect the public API and runtime semantics, so they need to be thought through in advance to avoid future breaking changes. That is also the main reason why I did not rush server generation earlier.

Because of that, I think a PR like this needs feature-level discussion before merge, to make sure the generated API and runtime behavior match the actual requirements.

I took a look at your implementation and found several concerns. Some of them are purely structural, but some are behavioral and should probably be discussed before merge.

First, I actually like the idea of having an Events field on the generated response type. Because ogen reuses the same response type on both client and server sides, that field also becomes part of the client API, but I think that is acceptable.

  1. In the current implementation, sse.Encoder.Encode always writes a data field. This breaks the semantics of metadata-only events such as retry-only events, because such frames become dispatched as normal events with empty data. In SSE, arbitrary combinations of event fields should be allowed without automatically emitting fields that were not set.

  2. The sse.Encoder buffer only grows and never shrinks, so one large event can permanently increase retained memory. Since this sse package is supposed to be efficient and performance-oriented, that case should be considered explicitly. I would expect some configurable shrink behavior, similar in spirit to how initialLineBufferCap exists on the client side.

  3. normalizeNewlines relies on strings.ReplaceAll, which may become unnecessarily expensive for large payloads. I think this should reuse sse.newlineNormalizer instead.

  4. In my opinion, there should definitely be automatic keep-alive comment sending by default, with an option to configure or disable it. My current idea is to add a KeepAlive field to the generated response type, similar to the newly added Events field, with a signature like func(context.Context, sse.Sender[E]) error. If KeepAlive is not set, the default behavior is for "keep-alive" comment to be sent every 15 seconds (this is recommended by the spec: https://html.spec.whatwg.org/multipage/server-sent-events.html#authoring-notes). If developer doesn't want to use automatic keep-alives, he would just set this function to an empty one.

So overall, I believe this feature should first go through a short design discussion, and only then move toward merge.

Comment on lines +99 to +200
{{ define "response_encoders/sse_event" }}
{{- /*gotype: github.com/ogen-go/ogen/gen.TypeElem*/ -}}{{ $t := $.Type }}
{{- /*gotype: github.com/ogen-go/ogen/gen.TemplateConfig*/ -}}{{ $cfg := $.Config }}
{{- $sse := $t.SSE }}
// encode{{ $t.Name }}Event encodes {{ $sse.EventType.Go }} as a Server-Sent Event.
func encode{{ $t.Name }}Event(event {{ $sse.EventType.Go }}) (sse.Event, error) {
{{- if eq $sse.Shape "data-only" }}
raw := sse.Event{
ID: event.ID,
Type: event.Type,
}
{{- if and $sse.DataType.NeedValidation $cfg.ResponseValidationEnabled }}
if err := func() error {
{{- template "validate" elem $sse.DataType "event.Data" }}
}(); err != nil {
return raw, errors.Wrap(err, "validate")
}
{{- end }}
if v, ok := event.Retry.Get(); ok {
retry := time.Duration(v) * time.Millisecond
raw.Retry = &retry
}
{{- if $sse.DataType.IsString }}
raw.Data = event.Data
{{- else }}
e := jx.GetEncoder()
defer jx.PutEncoder(e)
{{- template "json/enc" elem $sse.DataType "event.Data" }}
{{- if $sse.DataType.IsJSONString }}
// Data is a string, so it is sent as-is, without JSON quoting.
data, err := jx.DecodeBytes(e.Bytes()).Str()
if err != nil {
return raw, errors.Wrap(err, "encode data")
}
raw.Data = data
{{- else }}
raw.Data = string(e.Bytes())
{{- end }}
{{- end }}
return raw, nil
{{- else if or (eq $sse.Shape "full") (eq $sse.Shape "full-array") }}
var raw sse.Event
{{- if and $sse.EventType.NeedValidation $cfg.ResponseValidationEnabled }}
if err := func() error {
{{- template "validate" elem $sse.EventType "event" }}
}(); err != nil {
return raw, errors.Wrap(err, "validate")
}
{{- end }}
e := jx.GetEncoder()
defer jx.PutEncoder(e)
{{- template "json/enc" elem $sse.EventType "event" }}

// Event schema describes the full event envelope, so the standard fields
// are taken from the encoded object.
if err := jx.DecodeBytes(e.Bytes()).ObjBytes(func(d *jx.Decoder, key []byte) error {
if d.Next() == jx.Null {
return d.Skip()
}
switch string(key) {
case "id":
v, err := d.Str()
if err != nil {
return err
}
raw.ID = v
case "event":
v, err := d.Str()
if err != nil {
return err
}
raw.Type = v
case "retry":
v, err := d.Int64()
if err != nil {
return err
}
retry := time.Duration(v) * time.Millisecond
raw.Retry = &retry
case "data":
v, err := d.Raw()
if err != nil {
return err
}
// String data is sent as-is, any other value is sent as JSON.
if v.Type() == jx.String {
data, err := jx.DecodeBytes(v).Str()
if err != nil {
return err
}
raw.Data = data
return nil
}
raw.Data = string(v)
default:
return d.Skip()
}
return nil
}); err != nil {
return raw, errors.Wrap(err, "encode event")
}
return raw, nil

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.

This is actually strange. Based on the code, the logic is that depending on the SSE shape, the event is encoded differently.

But the thing is that SSE shape is spec representation only thing - it should not imply that some SSE fields are used and some are not. All represented SSE events should be encoded the same

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.

3 participants