Felipe Figueiredo
← All Projects

Project

Tiny Chat App AI

A live-coding brief taken past the brief: NestJS, a real model behind the endpoint, and the reply paced out over SSE so it arrives at reading speed instead of all at once.

TypeScriptReact 18ViteTailwindshadcn/uiNestJSRxJSServer-Sent Eventsclass-validatorGeminiJestDocker
Tiny Chat App AI

A live-coding exercise: a chat endpoint in NestJS, a chat box in React, one message in and one reply out. The brief allowed a hardcoded reply. I wired a real model behind it and streamed the answer back, which turned a two-hour exercise into a set of decisions worth defending.

Technical Decisions

A typing effect, on purpose

The reply reaches the browser one character at a time over Server-Sent Events, at a fixed 50 ms cadence. Worth being exact about what that is: the backend holds the model's complete answer before the first character leaves, so this buys perceived latency, not real latency. Nobody's answer arrives sooner.

That was the point. An answer that appears at reading speed reads as a reply; the same text appearing all at once reads as a lookup. And a fixed cadence is steadier than a genuine token stream, which arrives in bursts and stalls — for the impression of someone typing, constant beats true.

Holding the whole reply also keeps a guarantee that real streaming gives up. This assistant is fenced to one domain and instructed to decline anything outside it. With the complete answer in hand, that fence can be checked before a single character is shown. Once a token has been streamed, it cannot be unsaid.

Closing an EventSource without inventing an error

SSE has a trap: EventSource fires onerror on a normal server close, not only on failure. Handle it naively and every successful conversation ends with a connection error on screen. The stream therefore ends with an explicit [[END]] sentinel, the client closes the connection itself and sets a completion flag, and the error handler returns early when that flag is set. Three lines, and the difference between a demo that works and a demo that works and looks like it works.

Validation at the boundary, not in the handler

A global ValidationPipe plus a DTO carrying @IsString() and @IsNotEmpty() means an empty message is a 400 before the controller runs, and the rule lives in one declaration rather than in an if at the top of every method. The streaming route checks the query string by hand for the same reason the DTO exists — a GET has no body to validate.

A fence around the model

The system prompt scopes the assistant to Brazilian cuisine and instructs it to decline anything else, so the tests can assert on a bounded domain instead of on whatever a general-purpose model felt like saying. Provider credentials come from config, never from code, and a failed call surfaces as a 500 with a message the frontend can show rather than an unhandled rejection.

Two containers, one command

A Dockerfile per service and a Compose file that starts both, with the frontend depending on the backend. The bonus in the brief asked for a container; the reason to do both is that a reviewer should be able to run the thing without reading setup instructions.

Verification

Controller and service are tested through Nest's TestingModule with the LLM layer injected as a mock, so the suite covers the wiring, the validation and the error path without spending a single API call.