Files
What you'll learn
- Write an @Sse() handler that returns an Observable<MessageEvent>, and read the event-stream lines it produces
- Merge domain events from a subject with a periodic heartbeat, and explain what happens when the client disconnects
- Choose ReplaySubject, BehaviorSubject or Subject by what a late subscriber should see
Server-Sent Events
A shelter's website shows the cats, and when one is adopted the page should say so, now, not after the visitor refreshes. HTTP as you have used it so far cannot: the server only speaks when asked. Server-sent events are the simplest way round that. The client opens one ordinary GET request and the server keeps it open, writing a message down it whenever it has one, for as long as both sides like. No new protocol, no handshake, no library on the client beyond EventSource, which every browser ships. WebSockets go both ways and cost more; for "tell me when something happens", SSE is enough.
The wire
The response has Content-Type: text/event-stream and never ends. Each message is a few lines of text followed by a blank line:
id: 3
event: adopted
data: {"id":1,"name":"Tom"}
data is the payload, event an optional type the client can listen for by name, id lets a reconnecting client say where it left off, retry tells it how long to wait before reconnecting. Nest writes these lines for you.
@Sse
A handler decorated with @Sse() returns an Observable, and every value it emits becomes a message:
@Sse('sse')
sse(): Observable<MessageEvent> {
return interval(1000).pipe(map((_) => ({ data: { hello: 'world' } })));
}
MessageEvent is Nest's shape for one message: data (a string or an object, serialised as JSON), and optionally type, id and retry, which become the event, id and retry lines. The docs' example ticks every second forever; interval never completes, and neither does the response. A real stream is usually two things merged: the events that matter, from an rxjs Subject that some service calls next() on, and a heartbeat, a periodic message that keeps proxies from closing an idle connection and lets the client tell "quiet" from "dead". Merge them with merge(events$, interval(15000).pipe(map(...))).
When the client disconnects, Nest unsubscribes from the observable, so an interval stops ticking and a finalize() operator in the pipe runs cleanup. Nest 12 also offers @SseSignal(), an AbortSignal for the response's lifetime, for handlers that set something up asynchronously before returning the stream.
Late arrivals
A plain Subject gives a subscriber only what happens after it subscribed: a visitor who opens the page a second after an adoption never hears of it. ReplaySubject(n) remembers the last n values and replays them to each new subscriber, which is what a "recent adoptions" feed wants. BehaviorSubject holds one current value; a plain Subject forgets everything. Choose by what a late subscriber should see.
The client side is three lines:
const eventSource = new EventSource('/sse');
eventSource.onmessage = ({ data }) => {
console.log('New message', JSON.parse(data));
};
onmessage receives messages without an event line; a named type is heard with eventSource.addEventListener('adopted', ...).
Your task
The shelter announces adoptions live.
CatsServicekeeps a stream of adoption events that replays the last three to a late listener, and announces on it after every successful adoption.GET /cats/eventsis a server-sent events route: every adoption as a message of typeadoptedcarrying the cat, merged with a heartbeat of typeheartbeatevery 300 ms.
The request panel reads a stream for a moment and shows what arrived. Adopt a cat, then open /cats/events: the adoption comes first, replayed, then heartbeats.
When it fails
- The request hangs and times out: the handler returns an
Observablewithout@Sse(). Nest then waits for the observable to complete before answering, and anintervalnever does. - The response ends after one message: the observable completed;
of(...)or atake(1)ended it. A live stream stays open. - No
adoptedmessage, only heartbeats: the service never callednext(), or the subject is a plainSubjectand the adoption happened before the client connected. event: adoptedis missing and the client'saddEventListener('adopted')never fires: the message has notype.
Remember
@Sse()on a handler that returnsObservable<MessageEvent>; each emission is one message,dataplus optionaltype,idandretry.- Merge the events that matter with a heartbeat; the response stays open until the client leaves, and Nest unsubscribes then.
- A
ReplaySubject(n)gives late subscribers the lastnevents; aSubjectgives them nothing. - The client is
new EventSource(url), withonmessagefor untyped messages andaddEventListener(type)for typed ones.
Stuck? Show a hint
Service: private readonly announcements = new ReplaySubject<AdoptionEvent>(3); this.announcements.next({ type: 'adopted', cat: saved }) after the save; adoptions() returns this.announcements.asObservable(). Controller: @Sse('events') events(): Observable<MessageEvent> { return merge(this.catsService.adoptions().pipe(map((e) => ({ type: 'adopted', data: e.cat }) as MessageEvent)), interval(300).pipe(map((n) => ({ type: 'heartbeat', data: { n } }) as MessageEvent))); } with merge, interval and map from 'rxjs' and MessageEvent from '@nestjs/common'.
Press Run tests to start the app. Its log appears here.Graded endpoints
A cat to adopt
Saved, then announced on the stream; nobody is listening yet, and the replaying subject keeps it
text/event-stream, read for a moment: the replayed adoption as an 'adopted' event with the cat, then heartbeats
Refused, so nothing is announced