Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions sse/sse.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,17 @@ type bodyStreamer interface {
// Message is a single SSE message. There is no `event` field as this is
// handled by the `eventTypeMap` when registering the operation.
type Message struct {
ID int
Data any
// IDString, when set, is written to the SSE `id:` field and takes
// precedence over [Message.ID]. Unlike the numeric [Message.ID] it can
// carry opaque identifiers such as UUIDs, cursor tokens, composite keys,
// or the value "0" as the SSE specification allows.
IDString string
// ID is the numeric event ID written to the `id:` field when
// [Message.IDString] is empty. Deprecated: use [Message.IDString] instead.
ID int
// Data is the JSON-encoded payload of the message.
Data any
// Retry, if set, is the client reconnection delay in milliseconds.
Retry int
// Comment, if set, is written as one or more SSE comment lines (each line
// prefixed with a colon and ignored by clients). It may accompany an event
Expand Down Expand Up @@ -108,8 +117,11 @@ func Register[I any](api huma.API, op huma.Operation, eventTypeMap map[string]an
Type: huma.TypeObject,
Properties: map[string]*huma.Schema{
"id": {
Type: huma.TypeInteger,
Description: "The event ID.",
OneOf: []*huma.Schema{
{Type: huma.TypeInteger},
{Type: huma.TypeString},
},
},
"event": {
Type: huma.TypeString,
Expand Down Expand Up @@ -238,7 +250,14 @@ func stream[I any](reqCtx context.Context, w io.Writer, typeToEvent map[reflect.
}

// Write optional fields.
if msg.ID > 0 {
if msg.IDString != "" {
// CR, LF, and CRLF are all SSE line terminators. Strip them so an
// embedded line break can't inject other fields into the stream.
id := strings.ReplaceAll(msg.IDString, "\r\n", "")
id = strings.ReplaceAll(id, "\r", "")
id = strings.ReplaceAll(id, "\n", "")
w.Write([]byte("id: " + id + "\n"))
} else if msg.ID > 0 {
w.Write(fmt.Appendf(nil, "id: %d\n", msg.ID))
}
if msg.Retry > 0 {
Expand Down
7 changes: 7 additions & 0 deletions sse/sse_example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ func ExampleRegister_sse() {
Data: UserCreatedEvent{UserID: 1, Username: "foo"},
})

// Use `IDString` for opaque identifiers such as UUIDs or cursor tokens;
// it takes precedence over `ID`.
send(sse.Message{
IDString: "cursor-42",
Data: UserCreatedEvent{UserID: 1, Username: "foo"},
})

// Example "userDelete" event type.
send.Data(UserDeletedEvent{UserID: 2, Username: "bar"})

Expand Down
49 changes: 49 additions & 0 deletions sse/sse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,55 @@ data: {"message":"Hello, world!"}
"no body write should occur before the user handler sends an event")
},
},
{
Title: "sse string event ids",
TestFunc: func(t *testing.T) {
_, api := humatest.New(t)
sse.Register(api, huma.Operation{
OperationID: "sse",
Method: http.MethodGet,
Path: "/sse",
}, map[string]any{
"message": &DefaultMessage{},
}, func(ctx context.Context, input *struct{}, send sse.Sender) {
// String IDs take precedence over the legacy integer ID.
send(sse.Message{ID: 5, IDString: "stream-123:456", Data: DefaultMessage{Message: "one"}})
// Legacy integer IDs keep working when no string ID is set.
send(sse.Message{ID: 7, Data: DefaultMessage{Message: "two"}})
// String IDs may be "0" or other values the integer field cannot hold.
send(sse.Message{IDString: "0", Data: DefaultMessage{Message: "three"}})
// Line breaks cannot inject extra SSE fields.
send(sse.Message{IDString: "ab\r\ncd", Data: DefaultMessage{Message: "four"}})
})

resp := api.Get("/sse")

assert.Equal(t, http.StatusOK, resp.Code)
assert.Equal(t, `id: stream-123:456
data: {"message":"one"}

id: 7
data: {"message":"two"}

id: 0
data: {"message":"three"}

id: abcd
data: {"message":"four"}

`, resp.Body.String())

// The OpenAPI schema documents both integer and string IDs.
o := api.OpenAPI()
events := o.Paths["/sse"].Get.Responses["200"].Content["text/event-stream"].Schema.Items.Extensions["oneOf"].([]*huma.Schema)
idSchema := events[0].Properties["id"]
types := make([]string, len(idSchema.OneOf))
for i, s := range idSchema.OneOf {
types[i] = s.Type
}
assert.ElementsMatch(t, []string{"integer", "string"}, types)
},
},
{
Title: "sse stable event order in openapi",
TestFunc: func(t *testing.T) {
Expand Down