diff --git a/apps/landing/src/components/Features.tsx b/apps/landing/src/components/Features.tsx
index b7d1853..4f2e579 100644
--- a/apps/landing/src/components/Features.tsx
+++ b/apps/landing/src/components/Features.tsx
@@ -25,9 +25,9 @@ const features = [
desc: <>Scalar API reference at /docs and an OpenAPI spec at /openapi.json — generated, never stale.>,
},
{
- icon: ,
- title: "Just net/http",
- desc: <>Implements http.Handler. Works with any middleware, any router, any test framework. Zero lock-in.>,
+ icon: ,
+ title: "Real-Time",
+ desc: <>HandleSSE gives you typed server push with an sse helper. HandleWS adds typed bidirectional WebSockets with a connect helper.>,
},
];
diff --git a/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx b/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx
index f4e905f..ccd08b4 100644
--- a/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx
+++ b/apps/landing/src/content/docs/docs/core-concepts/handlers.mdx
@@ -218,4 +218,15 @@ shiftapi.Handle(api, "POST /users", createUser,
`WithError`, `WithMiddleware`, and `WithResponseHeader` are `Option` values — they work at all levels (API, group, and route). `WithStatus` and `WithRouteInfo` are route-only options. See [Options](/docs/core-concepts/options) for the full option system and composition.
+## Other handler types
+
+ShiftAPI provides specialized handler functions for different use cases:
+
+| Function | Use case |
+|----------|----------|
+| `Handle` | JSON API endpoints (this page) |
+| [`HandleSSE`](/docs/core-concepts/server-sent-events) | Server-Sent Events with a typed writer and auto-generated TypeScript `sse` helper |
+| [`HandleWS`](/docs/core-concepts/websockets) | Bidirectional WebSocket with typed send/receive, auto upgrade, and auto-generated TypeScript `websocket` helper |
+| [`HandleRaw`](/docs/core-concepts/raw-handlers) | File downloads, custom framing, and other responses that need direct `ResponseWriter` access |
+
See also: [Middleware](/docs/core-concepts/middleware), [Error Handling](/docs/core-concepts/error-handling), [Options](/docs/core-concepts/options).
diff --git a/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx b/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx
index dd5523a..73b6935 100644
--- a/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx
+++ b/apps/landing/src/content/docs/docs/core-concepts/raw-handlers.mdx
@@ -5,14 +5,15 @@ sidebar:
order: 7
---
-ShiftAPI's `Handle` function owns the response lifecycle — it JSON-encodes whatever your handler returns. But some responses can't be expressed as a typed struct: Server-Sent Events, file downloads, WebSocket upgrades, chunked streaming, and more. `HandleRaw` gives you full control over the `http.ResponseWriter` while keeping typed input parsing, validation, and middleware.
+ShiftAPI's `Handle` function owns the response lifecycle — it JSON-encodes whatever your handler returns. But some responses can't be expressed as a typed struct: file downloads, chunked streaming, and more. `HandleRaw` gives you full control over the `http.ResponseWriter` while keeping typed input parsing, validation, and middleware.
+
+For SSE and WebSocket use cases, prefer [`HandleSSE`](/docs/core-concepts/server-sent-events) and [`HandleWS`](/docs/core-concepts/websockets) which provide typed abstractions. Use `HandleRaw` when you need custom framing or non-standard behavior.
## Registering raw handlers
```go
shiftapi.HandleRaw(api, "GET /events", sseHandler)
shiftapi.HandleRaw(api, "GET /files/{id}", downloadHandler)
-shiftapi.HandleRaw(api, "GET /ws", wsHandler)
```
## Handler signature
@@ -186,10 +187,10 @@ The schema is generated using the same reflection logic as typed handlers — st
### No WithContentType (default)
-A `HandleRaw` route with no `WithContentType` produces a response with only a description. This is appropriate for WebSocket upgrades or other routes where the response format isn't meaningful to document:
+A `HandleRaw` route with no `WithContentType` produces a response with only a description. This is appropriate for routes where the response format isn't meaningful to document:
```go
-shiftapi.HandleRaw(api, "GET /ws", wsHandler)
+shiftapi.HandleRaw(api, "GET /proxy", proxyHandler)
```
Produces:
@@ -237,14 +238,16 @@ shiftapi.HandleRaw(api, "GET /events", sseHandler,
| `WithMiddleware(mw...)` | Apply HTTP middleware |
| `WithResponseHeader(name, value)` | Set a static response header |
-## When to use HandleRaw vs Handle
+## When to use HandleRaw vs Handle vs HandleSSE vs HandleWS
| Use case | Recommendation |
|----------|---------------|
| JSON API endpoint | `Handle` — let the framework encode the response |
-| Server-Sent Events (SSE) | `HandleRaw` + `WithContentType("text/event-stream")` |
+| Server-Sent Events (SSE) | [`HandleSSE`](/docs/core-concepts/server-sent-events) — typed writer, auto headers, typed TS client |
+| Bidirectional WebSocket | [`HandleWS`](/docs/core-concepts/websockets) — typed send/receive, auto upgrade, typed TS client |
+| SSE with custom framing | `HandleRaw` + `WithContentType("text/event-stream")` |
| File download | `HandleRaw` + `WithContentType("application/octet-stream")` |
-| WebSocket upgrade | `HandleRaw` (no `WithContentType` needed) |
+| Custom WebSocket framing | `HandleRaw` (no `WithContentType` needed) |
| Streaming response | `HandleRaw` with `Flusher` access |
| Proxy / passthrough | `HandleRaw` with `struct{}` input to preserve `r.Body` |
| JSON but custom content type | `Handle` + `WithContentType("application/vnd.api+json")` |
diff --git a/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx
new file mode 100644
index 0000000..5f6d46a
--- /dev/null
+++ b/apps/landing/src/content/docs/docs/core-concepts/server-sent-events.mdx
@@ -0,0 +1,437 @@
+---
+title: Server-Sent Events
+description: Use HandleSSE for type-safe Server-Sent Events with automatic OpenAPI spec generation and typed TypeScript clients.
+sidebar:
+ order: 8
+ badge:
+ text: Experimental
+ variant: caution
+---
+
+:::caution
+`HandleSSE` is experimental. The API may change in future releases.
+:::
+
+`HandleSSE` is a dedicated handler for Server-Sent Events that gives you a typed writer, automatic SSE headers, and end-to-end type safety from Go to TypeScript — including a generated `sse` helper and framework-specific hooks.
+
+## Registering SSE handlers
+
+```go
+shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Message]) error {
+ for msg := range messages(r.Context()) {
+ if err := sse.Send(msg); err != nil {
+ return err
+ }
+ }
+ return nil
+}, shiftapi.SSESends(
+ shiftapi.SSEEventType[Message]("message"),
+))
+```
+
+## Handler signature
+
+An SSE handler has two type parameters — input and event:
+
+```go
+func handler(r *http.Request, in InputType, sse *shiftapi.SSEWriter[EventType]) error
+```
+
+- **`r *http.Request`** — the standard HTTP request
+- **`in InputType`** — automatically decoded from path, query, header, body, or form — identical to `Handle`
+- **`sse *shiftapi.SSEWriter[EventType]`** — typed writer for sending events
+- **`error`** — return an error to stop the stream
+
+Use `struct{}` when the handler takes no input:
+
+```go
+shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Event]) error {
+ // ...
+}, shiftapi.SSESends(
+ shiftapi.SSEEventType[Event]("event"),
+))
+```
+
+## SSEWriter
+
+`SSEWriter[Event]` provides a single method for sending events:
+
+### Send
+
+`Send` automatically determines the event name from the concrete Go type registered via `SSESends`:
+
+```go
+sse.Send(Message{Text: "hello"})
+```
+
+Produces:
+
+```
+event: message
+data: {"text":"hello"}
+
+```
+
+`Send` JSON-encodes the value, writes it in SSE format, and flushes the response. On the first call, SSEWriter automatically sets the required headers:
+
+- `Content-Type: text/event-stream`
+- `Cache-Control: no-cache`
+- `Connection: keep-alive`
+
+## Input parsing
+
+Input decoding works identically to `Handle` — all struct tags (`path`, `query`, `header`, `json`, `form`) and validation rules apply:
+
+```go
+type EventInput struct {
+ Channel string `query:"channel" validate:"required"`
+ After int `query:"after"`
+}
+
+shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, in EventInput, sse *shiftapi.SSEWriter[Event]) error {
+ for event := range subscribe(r.Context(), in.Channel, in.After) {
+ if err := sse.Send(event); err != nil {
+ return err
+ }
+ }
+ return nil
+}, shiftapi.SSESends(
+ shiftapi.SSEEventType[Event]("event"),
+))
+```
+
+Path parameters work too:
+
+```go
+type StreamInput struct {
+ RoomID string `path:"room_id" validate:"required"`
+}
+
+shiftapi.HandleSSE(api, "GET /rooms/{room_id}/events", func(r *http.Request, in StreamInput, sse *shiftapi.SSEWriter[Event]) error {
+ // in.RoomID is parsed and validated
+}, shiftapi.SSESends(
+ shiftapi.SSEEventType[Event]("event"),
+))
+```
+
+## Error handling
+
+Error behavior depends on whether the handler has started sending events:
+
+- **Before sending** — if your handler returns an error and hasn't called `Send`, the framework handles it normally: matching `WithError` types, returning `422` for validation errors, or falling back to `500`.
+- **After sending** — if events have already been sent, it's too late to send an error response. The error is logged and the stream ends.
+
+```go
+shiftapi.HandleSSE(api, "GET /events", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Event]) error {
+ data, err := loadInitialData()
+ if err != nil {
+ return err // 500 (no events sent yet)
+ }
+
+ if err := sse.Send(Event{Data: data}); err != nil {
+ return err // logged, stream already started
+ }
+ return nil
+},
+ shiftapi.SSESends(shiftapi.SSEEventType[Event]("event")),
+ shiftapi.WithError[*AuthError](http.StatusUnauthorized),
+)
+```
+
+## OpenAPI spec
+
+`HandleSSE` automatically generates the correct OpenAPI spec with `text/event-stream` as the response content type. The `Event` type parameter is reflected into the schema:
+
+```go
+type ChatEvent struct {
+ User string `json:"user"`
+ Message string `json:"message"`
+}
+
+shiftapi.HandleSSE(api, "GET /chat", chatHandler,
+ shiftapi.SSESends(shiftapi.SSEEventType[ChatEvent]("chat")),
+)
+```
+
+Produces:
+
+```yaml
+paths:
+ /chat:
+ get:
+ responses:
+ '200':
+ description: OK
+ content:
+ text/event-stream:
+ schema:
+ $ref: '#/components/schemas/ChatEvent'
+```
+
+You don't need to use `WithContentType` or `ResponseSchema` — `HandleSSE` sets both automatically.
+
+## TypeScript client
+
+`shiftapi prepare` generates a fully-typed `sse` function constrained to SSE paths only — calling `sse` on a non-SSE path is a compile-time error.
+
+### sse
+
+`sse` returns an async iterable stream with a `close()` method:
+
+```typescript
+import { sse } from "@shiftapi/client";
+
+const stream = sse("/events", {
+ params: { query: { channel: "general" } },
+});
+
+for await (const event of stream) {
+ console.log(event);
+ // ^? ChatEvent — fully typed
+}
+```
+
+To stop the stream:
+
+```typescript
+stream.close();
+```
+
+You can also pass an `AbortSignal`:
+
+```typescript
+const controller = new AbortController();
+const stream = sse("/events", { signal: controller.signal });
+
+// later...
+controller.abort();
+```
+
+### Path parameters and headers
+
+Path parameters and request headers are type-checked. If your Go handler declares `path:"room_id"` or `header:"X-Token"` on the input struct, the generated types require them:
+
+```typescript
+const stream = sse("/rooms/{room_id}/events", {
+ params: {
+ path: { room_id: "abc" },
+ header: { "X-Token": "secret" },
+ },
+});
+```
+
+### React
+
+Use `sse` with React state. You control how events are accumulated:
+
+```tsx
+import { sse } from "@myapp/api";
+import { useEffect, useState } from "react";
+
+function ChatFeed() {
+ const [messages, setMessages] = useState([]);
+
+ useEffect(() => {
+ const stream = sse("/chat");
+ (async () => {
+ for await (const event of stream) {
+ setMessages((prev) => [...prev, event]);
+ }
+ })();
+ return () => stream.close();
+ }, []);
+
+ return (
+
+```
+
+## Route options
+
+All standard route options work with `HandleSSE`:
+
+```go
+shiftapi.HandleSSE(api, "GET /events", handler,
+ shiftapi.SSESends(shiftapi.SSEEventType[Event]("event")),
+ shiftapi.WithRouteInfo(shiftapi.RouteInfo{
+ Summary: "Subscribe to events",
+ Tags: []string{"events"},
+ }),
+ shiftapi.WithError[*AuthError](http.StatusUnauthorized),
+ shiftapi.WithMiddleware(auth),
+)
+```
+
+| Option | Description |
+|--------|-------------|
+| `WithRouteInfo(info)` | Set OpenAPI summary, description, and tags |
+| `WithError[T](code)` | Declare an error type at a status code |
+| `WithMiddleware(mw...)` | Apply HTTP middleware |
+| `WithResponseHeader(name, value)` | Set a static response header |
+| `SSESends(variants...)` | Declare discriminated union event types (see above) |
+
+## Discriminated union events
+
+For endpoints that emit multiple event types, use `SSESends` to declare each variant. This generates a `oneOf` schema with a `discriminator` in the OpenAPI spec, which produces **TypeScript discriminated unions** in the generated client.
+
+### Define a marker interface
+
+Use a Go marker interface to constrain the SSE writer's type parameter:
+
+```go
+type ChatEvent interface{ chatEvent() }
+
+type MessageData struct {
+ User string `json:"user"`
+ Text string `json:"text"`
+}
+func (MessageData) chatEvent() {}
+
+type JoinData struct {
+ User string `json:"user"`
+}
+func (JoinData) chatEvent() {}
+```
+
+### Register with SSESends
+
+Pass `SSEEventType[T]` descriptors to `SSESends` to declare each named event. When `SSESends` is used, `Send` automatically determines the event name from the concrete Go type — no need to pass the name manually:
+
+```go
+shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error {
+ if err := sse.Send(MessageData{User: "alice", Text: "hi"}); err != nil {
+ return err
+ }
+ return sse.Send(JoinData{User: "bob"})
+}, shiftapi.SSESends(
+ shiftapi.SSEEventType[MessageData]("message"),
+ shiftapi.SSEEventType[JoinData]("join"),
+))
+```
+
+### Generated OpenAPI schema
+
+`SSESends` produces a `oneOf` + `discriminator` schema:
+
+```yaml
+paths:
+ /chat:
+ get:
+ responses:
+ '200':
+ content:
+ text/event-stream:
+ schema:
+ oneOf:
+ - type: object
+ required: [event, data]
+ properties:
+ event:
+ type: string
+ enum: [message]
+ data:
+ $ref: '#/components/schemas/MessageData'
+ - type: object
+ required: [event, data]
+ properties:
+ event:
+ type: string
+ enum: [join]
+ data:
+ $ref: '#/components/schemas/JoinData'
+ discriminator:
+ propertyName: event
+```
+
+### TypeScript discriminated unions
+
+The generated TypeScript client automatically narrows event types based on the `event` field:
+
+```typescript
+import { sse } from "@shiftapi/client";
+
+const stream = sse("/chat");
+
+for await (const msg of stream) {
+ if (msg.event === "message") {
+ console.log(msg.data.text);
+ // ^? string — narrowed to MessageData
+ } else if (msg.event === "join") {
+ console.log(msg.data.user);
+ // ^? string — narrowed to JoinData
+ }
+}
+```
+
+### Single vs multi-event
+
+`SSESends` is always required. For endpoints with a single event type, pass a single `SSEEventType`:
+
+```go
+shiftapi.HandleSSE(api, "GET /ticks", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[Tick]) error {
+ return sse.Send(Tick{Time: time.Now()})
+}, shiftapi.SSESends(
+ shiftapi.SSEEventType[Tick]("tick"),
+))
+```
+
+With a single event type, the schema uses the type directly — no `oneOf`, no discriminator. Multiple event types produce a `oneOf` schema with a discriminator.
+
+## When to use HandleSSE vs HandleRaw
+
+| Use case | Recommendation |
+|----------|---------------|
+| SSE with typed events | `HandleSSE` — typed writer, auto headers, typed TS client |
+| SSE with custom framing | `HandleRaw` + `WithContentType("text/event-stream")` |
+| File download | `HandleRaw` + `WithContentType("application/octet-stream")` |
+| Bidirectional real-time | [`HandleWS`](/docs/core-concepts/websockets) — typed handlers, auto dispatch |
+| JSON API endpoint | `Handle` |
+
+`HandleSSE` is the recommended approach for SSE. Use [`HandleRaw`](/docs/core-concepts/raw-handlers) only when you need custom SSE framing or non-standard behavior.
diff --git a/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx b/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx
new file mode 100644
index 0000000..1fcd18a
--- /dev/null
+++ b/apps/landing/src/content/docs/docs/core-concepts/websockets.mdx
@@ -0,0 +1,500 @@
+---
+title: WebSockets
+description: Use HandleWS for type-safe bidirectional WebSocket communication with automatic AsyncAPI spec generation and typed TypeScript clients.
+sidebar:
+ order: 9
+ badge:
+ text: Experimental
+ variant: caution
+---
+
+:::caution
+`HandleWS` is experimental. The API may change in future releases.
+:::
+
+`HandleWS` is a dedicated handler for WebSocket connections that gives you typed message handlers, automatic upgrade handling, and end-to-end type safety from Go to TypeScript — including a generated `websocket` helper.
+
+## Registering WebSocket handlers
+
+```go
+shiftapi.HandleWS(api, "GET /echo",
+ shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
+ shiftapi.WSSends{shiftapi.WSMessageType[ServerMsg]("server")},
+ shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error {
+ return s.Send(ServerMsg{Text: "echo: " + msg.Text})
+ }),
+ ),
+)
+```
+
+## How it works
+
+`HandleWS` uses a **typed collector pattern** — you pass a setup function, send types, and typed handlers to `Websocket()` in a single call, then pass the result to `HandleWS`:
+
+- **`Websocket(setup, sends, handlers...)`** — creates a `*WSMessages[In]` value. Both `In` and `State` are inferred from the setup function's signature. The setup function runs after the WebSocket upgrade, receives the parsed input, and returns a `State` value that is passed to all handlers.
+- **`WSOn("name", fn)`** — registers a typed handler for messages with the given type name. The `State` and `Msg` type parameters are inferred from the handler function. `State` must match the setup function's return type.
+- **`WSSends{variants...}`** — a `[]WSMessageVariant` that registers named send types for auto-wrap envelopes and AsyncAPI schema generation.
+
+Everything is configured in one place — no mutation after construction.
+
+## WSOn handlers
+
+Each `WSOn` handler has this signature:
+
+```go
+func(sender *shiftapi.WSSender, state State, msg Msg) error
+```
+
+- **`sender *WSSender`** — for sending messages and accessing the connection context via `sender.Context()`
+- **`state State`** — the value returned by the setup function for this connection
+- **`msg Msg`** — the decoded message payload
+
+The framework reads `{"type": "name", "data": ...}` envelopes from the client, matches `type` to the registered `WSOn` name, and decodes `data` into `Msg`. Unknown types are logged and skipped by default — use `WSOnUnknownMessage` to customize this.
+
+## WSSender
+
+`WSSender` provides methods for sending messages and managing the connection:
+
+### Send
+
+Writes a JSON-encoded message wrapped in a `{"type": name, "data": value}` envelope. The event name is automatically determined from the concrete Go type registered via `WSSends`:
+
+```go
+s.Send(ChatMessage{User: "alice", Text: "hi"})
+// → {"type": "chat", "data": {"user": "alice", "text": "hi"}}
+```
+
+### Context
+
+Returns the connection's context, which is cancelled when the connection closes:
+
+```go
+ctx := s.Context()
+```
+
+### Close
+
+Closes the connection with a status code and reason:
+
+```go
+s.Close(shiftapi.WSStatusNormalClosure, "done")
+```
+
+## Input parsing
+
+Input decoding works identically to `Handle` — `path`, `query`, and `header` struct tags and validation rules all apply. Input is parsed **before** the WebSocket upgrade, so validation errors produce normal JSON error responses:
+
+```go
+type ChatInput struct {
+ Room string `path:"room" validate:"required"`
+ Token string `query:"token" validate:"required"`
+}
+
+type ChatState struct {
+ Room string
+ Token string
+}
+
+shiftapi.HandleWS(api, "GET /rooms/{room}",
+ shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, in ChatInput) (*ChatState, error) {
+ // in.Room and in.Token are parsed and validated before upgrade.
+ return &ChatState{Room: in.Room, Token: in.Token}, nil
+ },
+ shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")},
+ shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *ChatState, m UserMessage) error {
+ return s.Send(ChatMessage{Room: state.Room, Text: m.Text})
+ }),
+ ),
+)
+```
+
+The setup function receives the parsed `In` value and returns a `State` that carries any needed data to handlers.
+
+## Connection setup and state
+
+The setup function (the first argument to `Websocket`) runs after the WebSocket upgrade but before the dispatch loop starts. It returns a `State` value that is passed to every `WSOn` handler, providing type-safe per-connection state:
+
+```go
+type JoinInput struct {
+ Room string `path:"room" validate:"required"`
+}
+
+type RoomState struct {
+ Room *Room
+ User *User
+}
+
+shiftapi.HandleWS(api, "GET /rooms/{room}",
+ shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, in JoinInput) (*RoomState, error) {
+ user, err := authenticate(r)
+ if err != nil {
+ return nil, err // closes connection with StatusInternalError
+ }
+ room := rooms.Join(in.Room, user)
+ return &RoomState{Room: room, User: user}, nil
+ },
+ shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")},
+ shiftapi.WSOn("message", func(s *shiftapi.WSSender, state *RoomState, m UserMessage) error {
+ return s.Send(ChatMessage{Room: state.Room.Name, User: state.User.Name, Text: m.Text})
+ }),
+ ),
+)
+```
+
+The `State` type parameter is inferred from the setup function and enforced at compile time across all `WSOn` handlers — if a handler's `State` type doesn't match the setup function's return type, the code won't compile.
+
+If the setup function returns an error, the connection is closed with `WSStatusInternalError` and the error is logged.
+
+Use `struct{}` as the state type when no per-connection state is needed.
+
+:::note
+Use a **pointer type** for state (e.g. `*RoomState`) when handlers need to mutate shared state across messages. Value types are copied per handler call, so mutations would be lost.
+:::
+
+### Cleanup on disconnect
+
+The request context (`r.Context()`) is cancelled when the WebSocket connection closes. Use this in the setup function to run cleanup logic:
+
+```go
+func(r *http.Request, s *shiftapi.WSSender, in JoinInput) (*RoomState, error) {
+ room := rooms.Join(in.Room, user)
+ go func() {
+ <-r.Context().Done()
+ rooms.Leave(in.Room, user)
+ }()
+ return &RoomState{Room: room}, nil
+}
+```
+
+## Error handling
+
+Error behavior depends on when the error occurs:
+
+- **Before upgrade** — if input parsing or validation fails, a JSON error response is written (just like `Handle`).
+- **After upgrade** — if a `WSOn` handler returns an error, the connection is closed with `WSStatusInternalError` and the error is logged. Handle recoverable errors within the handler itself (return `nil` to continue).
+- **Decode errors** — if a client sends a malformed payload that cannot be decoded into the expected type, the framework logs the error and continues reading. The connection is not closed. Use `WSOnDecodeError` to customize this behavior.
+- **WebSocket close** — if a handler returns an error that is already a WebSocket close (e.g. client disconnect), the framework recognizes it and does not double-close.
+
+## Callbacks
+
+Callbacks are passed as `WSHandler` values to `Websocket()`, alongside `WSOn` handlers. They receive the same `(sender, state)` pair as message handlers:
+
+### WSOnDecodeError
+
+`WSOnDecodeError` registers a handler for message payloads that cannot be decoded into the expected type. If not registered, the framework logs the error and continues reading. The connection is never closed for decode errors.
+
+```go
+shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (*RoomState, error) {
+ return &RoomState{}, nil
+ },
+ shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")},
+ shiftapi.WSOn("message", handleMessage),
+ shiftapi.WSOnDecodeError(func(s *shiftapi.WSSender, state *RoomState, err *shiftapi.WSDecodeError) {
+ log.Printf("bad payload for %s: %v", err.MessageType(), err.Unwrap())
+ }),
+)
+```
+
+### WSOnUnknownMessage
+
+`WSOnUnknownMessage` registers a handler for messages whose `type` field does not match any registered `WSOn` handler. If not registered, the framework logs the unknown type and continues reading.
+
+```go
+shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (*RoomState, error) {
+ return &RoomState{}, nil
+ },
+ shiftapi.WSSends{shiftapi.WSMessageType[ChatMessage]("chat")},
+ shiftapi.WSOn("message", handleMessage),
+ shiftapi.WSOnUnknownMessage(func(s *shiftapi.WSSender, state *RoomState, msgType string, data json.RawMessage) {
+ log.Printf("unknown message type: %s", msgType)
+ }),
+)
+```
+
+## AsyncAPI spec
+
+WebSocket endpoints are documented in an [AsyncAPI 2.4](https://www.asyncapi.com/) spec served at `GET /asyncapi.json`. Each `HandleWS` call creates a channel with `subscribe` (server→client) and `publish` (client→server) operations derived from `WSSends` and `WSOn` registrations:
+
+```go
+shiftapi.HandleWS(api, "GET /echo",
+ shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
+ shiftapi.WSSends{shiftapi.WSMessageType[ServerMsg]("server")},
+ shiftapi.WSOn("echo", func(s *shiftapi.WSSender, _ struct{}, msg ClientMsg) error {
+ return s.Send(ServerMsg{Text: msg.Text})
+ }),
+ ),
+)
+```
+
+Produces:
+
+```yaml
+asyncapi: 2.4.0
+defaultContentType: application/json
+channels:
+ /echo:
+ subscribe:
+ message:
+ name: server
+ payload:
+ $ref: '#/components/schemas/ServerMsg'
+ publish:
+ message:
+ name: echo
+ payload:
+ $ref: '#/components/schemas/ClientMsg'
+```
+
+Message schemas are also registered in the OpenAPI spec's `components/schemas` so the TypeScript codegen can generate types from a single `openapi-typescript` pass.
+
+## TypeScript client
+
+`shiftapi prepare` generates a fully-typed `websocket` function constrained to WebSocket paths only — calling `websocket` on a non-WebSocket path is a compile-time error.
+
+### websocket
+
+`websocket` returns a `WSConnection` with typed `send`, `receive`, async iteration, and `close`:
+
+```typescript
+import { websocket } from "@shiftapi/client";
+
+const ws = websocket("/echo", {
+ params: { query: { token: "abc" } },
+});
+
+// Send a message
+ws.send({ text: "hello" });
+
+// Receive a single message
+const msg = await ws.receive();
+console.log(msg);
+// ^? ServerMsg — fully typed
+
+// Or iterate over all messages
+for await (const msg of ws) {
+ console.log(msg.text);
+}
+```
+
+To close the connection:
+
+```typescript
+ws.close(1000, "done");
+```
+
+### Path parameters
+
+Path parameters are type-checked. If your Go handler declares `path:"room"` on the input struct, the generated types require it:
+
+```typescript
+const ws = websocket("/rooms/{room}", {
+ params: {
+ path: { room: "general" },
+ query: { token: "secret" },
+ },
+});
+```
+
+### React
+
+Use `websocket` with React state for a chat-style component:
+
+```tsx
+import { websocket } from "@shiftapi/client";
+import { useEffect, useRef, useState } from "react";
+
+function Chat() {
+ const [messages, setMessages] = useState([]);
+ const wsRef = useRef> | null>(null);
+
+ useEffect(() => {
+ const ws = websocket("/echo");
+ wsRef.current = ws;
+ (async () => {
+ for await (const msg of ws) {
+ setMessages((prev) => [...prev, msg]);
+ }
+ })();
+ return () => ws.close();
+ }, []);
+
+ const send = (text: string) => {
+ wsRef.current?.send({ text });
+ };
+
+ return (
+
+
+ {messages.map((msg, i) => (
+
{msg.text}
+ ))}
+
+
+
+ );
+}
+```
+
+## Multi-type messages
+
+For endpoints that send multiple message types, use `WSSends` to register each variant. This generates `oneOf` schemas with a `discriminator` on the `type` field, producing **TypeScript discriminated unions**.
+
+### Register with WSSends
+
+Pass `WSMessageType[T]` descriptors to declare each named send type. `WSSender.Send` automatically wraps the value in a `{"type": name, "data": value}` envelope based on its concrete Go type:
+
+```go
+shiftapi.HandleWS(api, "GET /chat",
+ shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
+ shiftapi.WSSends{
+ shiftapi.WSMessageType[ChatMessage]("chat"),
+ shiftapi.WSMessageType[SystemMessage]("system"),
+ },
+ shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error {
+ return s.Send(ChatMessage{User: "server", Text: m.Text})
+ }),
+ ),
+)
+```
+
+### Wire format
+
+`WSSender.Send` wraps the payload automatically:
+
+```json
+{"type": "chat", "data": {"user": "server", "text": "hello"}}
+```
+
+The client sends messages in the same envelope format, and the framework dispatches to the matching `WSOn` handler:
+
+```json
+{"type": "message", "data": {"text": "hello"}}
+```
+
+### Generated AsyncAPI schema
+
+`WSSends` produces `oneOf` message definitions in the AsyncAPI spec with `{type, data}` envelope payloads:
+
+```yaml
+channels:
+ /chat:
+ subscribe:
+ message:
+ oneOf:
+ - $ref: '#/components/messages/chat_ChatMessage'
+ - $ref: '#/components/messages/system_SystemMessage'
+ publish:
+ message:
+ name: message
+ payload:
+ $ref: '#/components/schemas/UserMessage'
+```
+
+### TypeScript discriminated unions
+
+The generated TypeScript client automatically narrows message types based on the `type` field:
+
+```typescript
+import { websocket } from "@shiftapi/client";
+
+const ws = websocket("/chat");
+
+for await (const msg of ws) {
+ if (msg.type === "chat") {
+ console.log(msg.data.text);
+ // ^? string — narrowed to ChatMessage
+ } else if (msg.type === "system") {
+ console.log(msg.data.info);
+ // ^? string — narrowed to SystemMessage
+ }
+}
+```
+
+### Multiple receive handlers
+
+You can register multiple `WSOn` handlers for different client message types:
+
+```go
+shiftapi.HandleWS(api, "GET /chat",
+ shiftapi.Websocket(
+ func(r *http.Request, s *shiftapi.WSSender, _ struct{}) (struct{}, error) { return struct{}{}, nil },
+ shiftapi.WSSends{
+ shiftapi.WSMessageType[ChatMessage]("chat"),
+ shiftapi.WSMessageType[SystemMessage]("system"),
+ },
+ shiftapi.WSOn("message", func(s *shiftapi.WSSender, _ struct{}, m UserMessage) error {
+ return s.Send(ChatMessage{User: "echo", Text: m.Text})
+ }),
+ shiftapi.WSOn("command", func(s *shiftapi.WSSender, _ struct{}, cmd UserCommand) error {
+ return s.Send(SystemMessage{Info: "executed: " + cmd.Command})
+ }),
+ ),
+)
+```
+
+Each `WSOn` provides both runtime dispatch and AsyncAPI schema generation — no separate type registration needed for receive messages.
+
+## Origin checking
+
+By default, `HandleWS` rejects WebSocket connections from different origins. In dev mode (`shiftapidev` build tag, set automatically by the Vite and Next.js plugins), origin checking is disabled so that cross-origin requests from the frontend dev server work without extra configuration.
+
+In production, you must either serve the frontend and API from the same origin or allow specific origins with `WithWSAcceptOptions`:
+
+```go
+shiftapi.HandleWS(api, "GET /ws", ws,
+ shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{
+ OriginPatterns: []string{"example.com"},
+ }),
+)
+```
+
+## Route options
+
+Standard route options and WS-specific options are passed to `HandleWS` (not to `Websocket`):
+
+```go
+shiftapi.HandleWS(api, "GET /ws", ws,
+ shiftapi.WithRouteInfo(shiftapi.RouteInfo{
+ Summary: "WebSocket chat",
+ Tags: []string{"chat"},
+ }),
+ shiftapi.WithError[*AuthError](http.StatusUnauthorized),
+ shiftapi.WithMiddleware(auth),
+ shiftapi.WithWSAcceptOptions(shiftapi.WSAcceptOptions{
+ OriginPatterns: []string{"example.com"},
+ }),
+)
+```
+
+| Option | Description |
+|--------|-------------|
+| `WithRouteInfo(info)` | Set AsyncAPI summary, description, and tags |
+| `WithError[T](code)` | Declare an error type at a status code (pre-upgrade only) |
+| `WithMiddleware(mw...)` | Apply HTTP middleware |
+| `WithWSAcceptOptions(opts)` | Configure WebSocket upgrade (origins, subprotocols) |
+
+The following are arguments to `Websocket()`:
+
+| Argument | Description |
+|----------|-------------|
+| `setup` | Connection setup function — returns `(State, error)` (required, first arg) |
+| `WSSends{...}` | Server-to-client message types (required, second arg) |
+| `WSOn("name", fn)` | Typed message handler — variadic (required, at least one) |
+| `WSOnDecodeError(fn)` | Handle malformed message payloads (default: log + continue) |
+| `WSOnUnknownMessage(fn)` | Handle unrecognized message types (default: log + continue) |
+
+## When to use HandleWS vs HandleSSE vs Handle
+
+| Use case | Recommendation |
+|----------|---------------|
+| Bidirectional real-time (chat, games) | `HandleWS` — typed handlers, auto dispatch, auto upgrade |
+| Server push only (feeds, notifications) | [`HandleSSE`](/docs/core-concepts/server-sent-events) — simpler, works through proxies |
+| JSON API endpoint | `Handle` — let the framework encode the response |
+| Custom WebSocket framing | `HandleRaw` — direct `ResponseWriter` control |
diff --git a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx
index 72438ee..ceb5073 100644
--- a/apps/landing/src/content/docs/docs/frontend/nextjs.mdx
+++ b/apps/landing/src/content/docs/docs/frontend/nextjs.mdx
@@ -59,3 +59,40 @@ console.log(data.message);
```
The plugin handles starting your Go server, proxying requests, and regenerating types — just like the Vite plugin.
+
+### Server-Sent Events
+
+The generated client includes an `sse` function for SSE endpoints registered with `HandleSSE`. Use it directly or with the `useSSE` hook from your API package:
+
+```tsx
+import { useSSE } from "@/api";
+
+function EventFeed() {
+ const { data, error } = useSSE("/events");
+
+ if (error) return
Error: {error.message}
;
+ if (!data) return
Connecting...
;
+
+ return
{data.message}
;
+}
+```
+
+See [Server-Sent Events](/docs/core-concepts/server-sent-events) for full details.
+
+### WebSockets
+
+The generated client also includes a `connect` function for WebSocket endpoints registered with `HandleWS`. It returns a typed bidirectional connection:
+
+```typescript
+import { connect } from "@shiftapi/client";
+
+const ws = connect("/chat");
+
+ws.send({ text: "hello" });
+
+for await (const msg of ws) {
+ console.log(msg.text); // fully typed from your Go Send struct
+}
+```
+
+See [WebSockets](/docs/core-concepts/websockets) for full details.
diff --git a/apps/landing/src/content/docs/docs/frontend/vite.mdx b/apps/landing/src/content/docs/docs/frontend/vite.mdx
index 94a20ab..e9e447e 100644
--- a/apps/landing/src/content/docs/docs/frontend/vite.mdx
+++ b/apps/landing/src/content/docs/docs/frontend/vite.mdx
@@ -67,3 +67,51 @@ console.log(data.message);
```
Types are regenerated automatically when you save a Go file — changes appear in your editor via Vite's HMR.
+
+### Server-Sent Events
+
+The generated client includes an `sse` function for SSE endpoints registered with `HandleSSE`. It returns a typed async iterable:
+
+```typescript
+import { sse } from "@shiftapi/client";
+
+const stream = sse("/events");
+
+for await (const event of stream) {
+ console.log(event); // fully typed from your Go Event struct
+}
+```
+
+For Svelte projects, use the `createSSE` store from your API package:
+
+```svelte
+
+
+{#if $events.data}
+
{$events.data.message}
+{/if}
+```
+
+See [Server-Sent Events](/docs/core-concepts/server-sent-events) for full details.
+
+### WebSockets
+
+The generated client also includes a `connect` function for WebSocket endpoints registered with `HandleWS`. It returns a typed bidirectional connection:
+
+```typescript
+import { connect } from "@shiftapi/client";
+
+const ws = connect("/chat");
+
+ws.send({ text: "hello" });
+
+for await (const msg of ws) {
+ console.log(msg.text); // fully typed from your Go Send struct
+}
+```
+
+See [WebSockets](/docs/core-concepts/websockets) for full details.
diff --git a/apps/landing/src/content/docs/docs/getting-started/introduction.mdx b/apps/landing/src/content/docs/docs/getting-started/introduction.mdx
index 5257920..2538f94 100644
--- a/apps/landing/src/content/docs/docs/getting-started/introduction.mdx
+++ b/apps/landing/src/content/docs/docs/getting-started/introduction.mdx
@@ -21,5 +21,7 @@ ShiftAPI is a Go framework that generates an OpenAPI 3.1 spec from your handler
- **Composable options** — bundle middleware, errors, and other options into reusable `Option` values with `ComposeOptions`
- **Typed HTTP headers** — parse, validate, and document HTTP headers with `header` struct tags
- **File uploads** — declare uploads with `form` tags, get correct `multipart/form-data` types
+- **Server-Sent Events** — `HandleSSE` gives you a typed writer, automatic SSE headers, and a generated `sse` helper with React/Svelte hooks
+- **WebSockets** — `HandleWS` gives you typed message handlers with automatic upgrade handling and a generated `websocket` helper
- **Interactive docs** — Scalar API reference at `/docs` and OpenAPI spec at `/openapi.json`
- **Just `net/http`** — implements `http.Handler`, works with any middleware or router
diff --git a/asyncapi.go b/asyncapi.go
new file mode 100644
index 0000000..4ee7629
--- /dev/null
+++ b/asyncapi.go
@@ -0,0 +1,230 @@
+package shiftapi
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "reflect"
+
+ "github.com/getkin/kin-openapi/openapi3"
+ spec "github.com/swaggest/go-asyncapi/spec-2.4.0"
+)
+
+// addWSChannel registers a WebSocket endpoint in the AsyncAPI spec.
+// It creates a channel with subscribe (server→client) and publish
+// (client→server) operations, and registers schemas in both the
+// AsyncAPI and OpenAPI specs.
+func (a *API) addWSChannel(
+ path string,
+ sendType, recvType reflect.Type,
+ sendVariants []WSMessageVariant,
+ recvVariants []WSMessageVariant,
+ info *RouteInfo,
+ pathFields map[string]reflect.StructField,
+) error {
+ channelItem := spec.ChannelItem{}
+
+ // Path parameters.
+ for _, match := range pathParamRe.FindAllStringSubmatch(path, -1) {
+ name := match[1]
+ paramSchema := map[string]interface{}{"type": "string"}
+ if field, ok := pathFields[name]; ok {
+ paramSchema = goTypeToJSONSchema(field.Type)
+ }
+ if channelItem.Parameters == nil {
+ channelItem.Parameters = make(map[string]spec.Parameter)
+ }
+ channelItem.Parameters[name] = spec.Parameter{Schema: paramSchema}
+ }
+
+ // Subscribe = what clients receive = our Send type (server→client).
+ if sendType != nil || len(sendVariants) > 0 {
+ subMsg, err := a.buildWSMessage(sendType, sendVariants)
+ if err != nil {
+ return fmt.Errorf("send message: %w", err)
+ }
+ channelItem.Subscribe = &spec.Operation{
+ ID: operationID("subscribe", path),
+ Message: subMsg,
+ }
+ }
+
+ // Publish = what clients send = our Recv type (client→server).
+ if recvType != nil || len(recvVariants) > 0 {
+ pubMsg, err := a.buildWSMessage(recvType, recvVariants)
+ if err != nil {
+ return fmt.Errorf("recv message: %w", err)
+ }
+ channelItem.Publish = &spec.Operation{
+ ID: operationID("publish", path),
+ Message: pubMsg,
+ }
+ }
+
+ if info != nil {
+ channelItem.Description = info.Description
+ for _, op := range []*spec.Operation{channelItem.Subscribe, channelItem.Publish} {
+ if op == nil {
+ continue
+ }
+ op.Summary = info.Summary
+ for _, t := range info.Tags {
+ op.Tags = append(op.Tags, spec.Tag{Name: t})
+ }
+ }
+ }
+
+ a.asyncSpec.WithChannelsItem(path, channelItem)
+ return nil
+}
+
+// buildWSMessage builds an AsyncAPI Message for a single direction of a
+// WebSocket channel. For single-type endpoints it produces a direct message
+// reference. For multi-type endpoints (variants) it produces a oneOf wrapper.
+func (a *API) buildWSMessage(t reflect.Type, variants []WSMessageVariant) (*spec.Message, error) {
+ if len(variants) > 0 {
+ return a.buildWSOneOfMessage(variants)
+ }
+ return a.buildWSSingleMessage(t)
+}
+
+// buildWSSingleMessage creates a message with an inline payload reference to
+// the schema in components/schemas. No components/messages entry is created
+// for the simple single-type case.
+func (a *API) buildWSSingleMessage(t reflect.Type) (*spec.Message, error) {
+ name, err := a.registerWSSchema(t)
+ if err != nil {
+ return nil, err
+ }
+
+ msg := &spec.Message{}
+ msg.OneOf1Ens().WithMessageEntity(spec.MessageEntity{
+ Name: name,
+ Payload: map[string]interface{}{"$ref": "#/components/schemas/" + name},
+ })
+ return msg, nil
+}
+
+// buildWSOneOfMessage creates a oneOf message from discriminated variants.
+// Each variant gets an envelope schema {type, data} registered in components.
+func (a *API) buildWSOneOfMessage(variants []WSMessageVariant) (*spec.Message, error) {
+ var msgs []spec.Message
+
+ for _, v := range variants {
+ // Register the payload schema.
+ payloadName, err := a.registerWSSchema(v.messagePayloadType())
+ if err != nil {
+ return nil, err
+ }
+
+ // Build envelope schema: {"type": name, "data": payload}
+ envelopeName := v.messageName() + "_" + payloadName
+ envelopeSchema := map[string]interface{}{
+ "type": "object",
+ "required": []string{"type", "data"},
+ "properties": map[string]interface{}{
+ "type": map[string]interface{}{
+ "type": "string",
+ "enum": []interface{}{v.messageName()},
+ },
+ "data": map[string]interface{}{
+ "$ref": "#/components/schemas/" + payloadName,
+ },
+ },
+ }
+ a.asyncSpec.ComponentsEns().WithSchemasItem(envelopeName, envelopeSchema)
+
+ // Register envelope message in components.
+ envelopeMsg := spec.Message{}
+ envelopeMsg.OneOf1Ens().WithMessageEntity(spec.MessageEntity{
+ Name: v.messageName(),
+ Payload: map[string]interface{}{"$ref": "#/components/schemas/" + envelopeName},
+ })
+ a.asyncSpec.ComponentsEns().WithMessagesItem(envelopeName, envelopeMsg)
+
+ msgs = append(msgs, spec.Message{
+ Reference: &spec.Reference{Ref: "#/components/messages/" + envelopeName},
+ })
+ }
+
+ result := &spec.Message{}
+ result.OneOf1Ens().WithOneOf0(spec.MessageOneOf1OneOf0{OneOf: msgs})
+ return result, nil
+}
+
+// registerWSSchema registers a Go type as a schema in both the AsyncAPI and
+// OpenAPI component sections, returning the schema name.
+func (a *API) registerWSSchema(t reflect.Type) (string, error) {
+ schema, err := a.generateSchemaRef(t)
+ if err != nil {
+ return "", err
+ }
+ if schema == nil {
+ return "", fmt.Errorf("could not generate schema for %v", t)
+ }
+
+ name := schema.Ref
+ if name == "" {
+ name = t.Name()
+ }
+
+ // Register in OpenAPI components (for openapi-typescript type generation).
+ if schema.Ref != "" && len(schema.Value.Properties) > 0 {
+ a.spec.Components.Schemas[name] = &openapi3.SchemaRef{Value: schema.Value}
+ }
+
+ // Register in AsyncAPI components.
+ asyncSchema, err := openAPISchemaToMap(schema)
+ if err != nil {
+ return "", err
+ }
+ a.asyncSpec.ComponentsEns().WithSchemasItem(name, asyncSchema)
+
+ return name, nil
+}
+
+// openAPISchemaToMap converts a kin-openapi SchemaRef to a plain map for use
+// in the AsyncAPI spec's JSON Schema fields.
+func openAPISchemaToMap(s *openapi3.SchemaRef) (map[string]interface{}, error) {
+ b, err := json.Marshal(s.Value)
+ if err != nil {
+ return nil, err
+ }
+ var m map[string]interface{}
+ if err := json.Unmarshal(b, &m); err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+// goTypeToJSONSchema returns a minimal JSON Schema map for a scalar Go type.
+func goTypeToJSONSchema(t reflect.Type) map[string]interface{} {
+ for t.Kind() == reflect.Pointer {
+ t = t.Elem()
+ }
+ switch t.Kind() {
+ case reflect.Bool:
+ return map[string]interface{}{"type": "boolean"}
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
+ reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
+ return map[string]interface{}{"type": "integer"}
+ case reflect.Float32, reflect.Float64:
+ return map[string]interface{}{"type": "number"}
+ default:
+ return map[string]interface{}{"type": "string"}
+ }
+}
+
+func (a *API) serveAsyncSpec(w http.ResponseWriter, r *http.Request) {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetIndent("", " ")
+ if err := enc.Encode(a.asyncSpec); err != nil {
+ http.Error(w, "error encoding async spec", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+ _, _ = buf.WriteTo(w)
+}
diff --git a/doc.go b/doc.go
index b53af96..be0e7e7 100644
--- a/doc.go
+++ b/doc.go
@@ -119,6 +119,66 @@
// Registering a route with status 204 or 304 and a response type that has JSON body
// fields panics at startup — this catches misconfigurations early.
//
+// # Server-Sent Events
+//
+// Use [HandleSSE] for Server-Sent Events with a typed event writer:
+//
+// type ChatEvent struct {
+// User string `json:"user"`
+// Message string `json:"message"`
+// }
+//
+// shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error {
+// for event := range events(r.Context()) {
+// if err := sse.Send(event); err != nil {
+// return err
+// }
+// }
+// return nil
+// }, shiftapi.SSESends(
+// shiftapi.SSEEventType[ChatEvent]("chat"),
+// ))
+//
+// [SSEWriter] automatically sets Content-Type, Cache-Control, and Connection
+// headers on the first write. [SSEWriter.Send] automatically determines the
+// event name from the concrete Go type registered via [SSESends].
+//
+// [SSESends] is required for [HandleSSE]. It registers event types for
+// auto-wrap and OpenAPI schema generation. For multiple event types, define
+// a marker interface:
+//
+// type ChatEvent interface{ chatEvent() }
+//
+// type MessageData struct {
+// User string `json:"user"`
+// Text string `json:"text"`
+// }
+// func (MessageData) chatEvent() {}
+//
+// type JoinData struct {
+// User string `json:"user"`
+// }
+// func (JoinData) chatEvent() {}
+//
+// shiftapi.HandleSSE(api, "GET /chat", func(r *http.Request, _ struct{}, sse *shiftapi.SSEWriter[ChatEvent]) error {
+// sse.Send(MessageData{User: "alice", Text: "hi"})
+// return sse.Send(JoinData{User: "bob"})
+// }, shiftapi.SSESends(
+// shiftapi.SSEEventType[MessageData]("message"),
+// shiftapi.SSEEventType[JoinData]("join"),
+// ))
+//
+// [SSESends] generates a oneOf schema with a discriminator in the OpenAPI spec,
+// which produces TypeScript discriminated unions in the generated client.
+//
+// The generated TypeScript client includes a typed subscribe function
+// constrained to SSE paths. It handles path/query/header parameter
+// substitution, SSE stream parsing, and yields typed events as an async
+// iterable.
+//
+// For custom SSE framing or non-standard behavior, use [HandleRaw] with
+// [WithContentType]("text/event-stream") instead.
+//
// # Route groups
//
// Use [API.Group] to create a sub-router with a shared path prefix and options.
diff --git a/docs.go b/docs.go
index 6928686..5c2d467 100644
--- a/docs.go
+++ b/docs.go
@@ -24,15 +24,41 @@ Scalar.createApiReference('#app', {