| name | add-sse-endpoint |
| description | Add a Server-Sent Events (SSE) endpoint that stays inside the OpenAPI-driven contract. Use when the user wants streaming/long-lived push from Go to the renderer and WebSocket is not required. Covers registering `text/event-stream` in Huma, declaring a discriminated-union event schema, and writing the thin TS parser since `openapi-typescript` only types the stream body as `string`. |
When to use SSE vs WebSocket
Default to SSE. Choose WS only if you need client-to-server frames or binary frames. SSE stays inside OpenAPI (so types flow through schema.ts); WS does not (see the add-ws-frames skill for the WS escape hatch).
Workflow
1. Define the event payload as a named OneOf schema in Go
Declare each event variant as its own struct, then a wrapper that Huma will register as a discriminated union. Use a type discriminator field so the TS parser can switch on it.
type ProgressEvent struct {
Type string `json:"type" enum:"progress" doc:"Discriminator."`
Percent float64 `json:"percent" minimum:"0" maximum:"100"`
}
type DoneEvent struct {
Type string `json:"type" enum:"done" doc:"Discriminator."`
Result string `json:"result"`
}
type ErrorEvent struct {
Type string `json:"type" enum:"error" doc:"Discriminator."`
Message string `json:"message"`
}
2. Register the operation with text/event-stream
Huma supports streaming responses. The body type for SSE is conventionally written as a sealed interface or a oneOf schema; consult Huma's current docs for the exact registration helper before coding. Register the variant structs as named components so they end up in openapi.yaml and are emitted into schema.ts. The HTTP response content type must be text/event-stream.
Key requirements:
- Operation
Method: http.MethodGet, Path: "/feature/stream".
- Response
Content-Type: text/event-stream.
- Each event payload must match one of the registered variant schemas, encoded as JSON in the
data: field.
- Flush after every event (
http.Flusher) or buffering kills the stream.
3. Write the TS parser (this is the manual part)
openapi-typescript emits the SSE body as string. We must split the stream into events ourselves and narrow to the discriminated union. Put the parser next to the call site under electron/src/api/. Import the variant types from the generated schema.ts — do not redeclare them.
Sketch:
import type { components } from "./schema";
type StreamEvent =
| components["schemas"]["ProgressEvent"]
| components["schemas"]["DoneEvent"]
| components["schemas"]["ErrorEvent"];
export async function* readSseEvents(
resp: Response,
): AsyncGenerator<StreamEvent> {
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
while (true) {
const { value, done } = await reader.read();
if (done) return;
buf += decoder.decode(value, { stream: true });
let sep: number;
while ((sep = buf.indexOf("\n\n")) !== -1) {
const raw = buf.slice(0, sep);
buf = buf.slice(sep + 2);
const dataLine = raw
.split("\n")
.find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const json = dataLine.slice(5).trim();
if (!json) continue;
yield JSON.parse(json) as StreamEvent;
}
}
}
The caller switches on event.type and gets full type narrowing because the variant types each fix type to a string literal via the enum: tag.
4. Calling from the renderer
openapi-fetch returns Response for non-JSON bodies. Pass that to the parser:
const client = await getClient();
const { response } = await client.GET("/feature/stream", { parseAs: "stream" });
for await (const ev of readSseEvents(response)) {
switch (ev.type) {
case "progress": break;
case "done": break;
case "error": break;
}
}
5. Regenerate and verify
task openapi && task codegen
task verify
Inspect electron/src/api/schema.ts to confirm each event variant appears under components.schemas and the operation's response uses the union.
Hard rules
- The variant schemas must be registered as named OpenAPI components. Inline anonymous structs produce ugly TS types and break narrowing.
- Discriminator field is
type with a string literal (enum:"progress" etc). Do not use a numeric tag.
- TS parser is the only TS code allowed to interpret raw stream text. Everything downstream consumes the discriminated union.
- If you find yourself wanting bidirectional messaging, stop and switch to WS (see
add-ws-frames).
Definition of done
openapi.yaml contains the operation with text/event-stream response and named OneOf payload.
schema.ts reflects the variant components and the operation.
- Parser yields a discriminated union; renderer code typechecks under
task typecheck.
task verify passes.