The SSE Engineering Inflection Point: Why Your Frontend Needs a BFF Layer
The Engineering Inflection Point of SSE Streaming Output — Why You Need a BFF Layer
In the last article, we built a streaming output demo from scratch — fetching the DeepSeek API directly in the frontend, using ReadableStream + buffer + SSE parsing to render tokens onto the page one by one. The code ran, but it raised three soul-searching questions:
- The API Key is directly exposed in the frontend code — Open F12, and the Key in
.env.localis visible at a glance. Is this really secure?- All the streaming parsing logic is piled into the Vue component — buffer truncation recovery, SSE line parsing, JSON.parse fault tolerance... these low-level dirty jobs are mixed into the UI component. Is the code still maintainable?
- Cross-origin issues — The frontend runs on
localhost:5173, the backend runs onlocalhost:3000, and the same-origin policy directly blocks your fetch requests.This article is not a "sequel" to the previous one, but an engineering inflection point — we no longer let the frontend call the LLM API directly. Instead, we insert a BFF (Backend For Frontend) layer in the middle, letting the server solve the three problems of security, complexity, and cross-origin access. If you haven't read the previous article, it's recommended to understand binary streams, the SSE protocol, and the buffer mechanism first. This article builds on that foundation and moves up a level.
1. SSE — A One-Way Channel Where the Server Actively "Pushes" Data to the Frontend
1.1 What is SSE
SSE (Server-Sent Events) is a protocol that allows the server to unidirectionally push data to the browser.
WebSocket (Full-Duplex) SSE (Unidirectional Push)
┌──────────┐ ←→ ┌──────────┐ ┌──────────┐ ← ┌──────────┐
│ Browser │ ←→ │ Server │ │ Browser │ ← │ Server │
│ │ ←→ │ │ │ │ ← │ │
└──────────┘ ←→ └──────────┘ └──────────┘ ← └──────────┘
Both sides can send messages Only the server can send messages
Use cases: Chat, collaborative editing Use cases: LLM streaming output, stock tickers, notifications
Protocol: ws:// upgrade Protocol: Plain HTTP, Content-Type: text/event-stream
Complexity: High (heartbeat, reconnect, Complexity: Low (data: line + newline = one message)
frame protocol)
Thought: Why is LLM streaming output naturally suited for SSE rather than WebSocket?
Because the interaction pattern of an LLM is naturally one-way — the frontend sends one request (prompt), and the server continuously pushes generated tokens. The frontend does not need to send anything else to the server during this process. WebSocket's full-duplex capability is overkill here, and the extra handshake overhead, heartbeat maintenance, and frame protocol parsing become a burden instead.
1.2 SSE Data Format (Review)
We already dissected the SSE format in depth in the previous article. Here's a quick review with a diagram:
HTTP Response:
Content-Type: text/event-stream
Transfer-Encoding: chunked
data: {"id":"xxx","choices":[{"delta":{"content":"From"}}]}
data: {"id":"xxx","choices":[{"delta":{"content":"the"}}]}
data: [DONE]
data:— The event data prefix for SSE, followed by a JSON string\n\n— Two newline characters delimit one message[DONE]— An explicit signal that the stream has ended
2. The BFF Layer — A "Middleman" Tailored for the Frontend
2.1 What is BFF?
BFF = Backend For Frontend. It is fundamentally different from a traditional backend (pure backend server):
Traditional Backend Server BFF Layer
┌────────────────────────┐ ┌────────────────────────┐
│ Java / Go / Node │ │ Node (maintained by full-stack │
│ MVC architecture │ │ frontend engineers) │
│ CRUD APIs │ │ Customized services for │
│ RESTful design │ │ frontend interfaces │
│ Focus: Stability, │ │ Aggregate/trim backend │
│ concurrency, security │ │ interfaces │
│ Maintained by backend │ │ Handle streaming data, │
│ engineers │ │ encoding/decoding │
└────────────────────────┘ │ Focus: Frontend experience, │
│ development efficiency │
│ Maintained by full-stack │
│ frontend engineers │
└────────────────────────┘
The "MVC development pattern" (Model-View-Controller) mentioned in the README notes is the most classic design pattern for traditional backends:
- Model — The data layer, responsible for interacting with the database and defining data structures
- View — The presentation layer, which in traditional backends is usually an HTML page rendered by a template engine
- Controller — The logic layer, which receives requests, calls the Model, and returns the View
After the separation of frontend and backend, the View layer was taken over by frontend frameworks (Vue/React), and the backend's View degenerated into a JSON serializer.
2.2 Why Add a BFF Layer? — Three Problems That Must Be Faced
The README notes describe the dilemma of full-stack frontend engineers this way:
"JS frontend, the backend has many requirements. If an interface needs to be changed, the full-stack frontend engineer writes a common Node service themselves to meet their own needs."
The real scenario behind this statement is: the frontend needs a new interface (for example, aggregating data from three backend microservices into one response), but the backend team's schedule is booked until next month. The full-stack frontend engineer's choice is — write a Node.js service themselves and put it in the middle. This is the origin of BFF.
In our streaming output scenario, the BFF layer solves three specific problems:
Problem 1: API Key Security
┌─────────────┐ fetch + API Key ┌─────────────┐
│ Browser │ ────────────────────→ │ DeepSeek API │
│ Frontend │ │ │
│ F12 → Source │ API Key exposed in │ │
│ → Key leaked │ the client! │ │
└─────────────┘ └─────────────┘
After adding BFF:
┌──────────┐ fetch (no Key) ┌──────────┐ fetch + Key ┌──────────┐
│ Browser │ ───────────────→ │ Node BFF │ ────────────→ │ DeepSeek │
│ Frontend │ │ (3000) │ │ API │
│ Can't see │ │ Key on │ │ │
│ the Key │ │ server │ │ │
└──────────┘ └──────────┘ └──────────┘
Problem 2: Streaming Parsing Complexity
Frontend component 200 lines → 50% is streaming parsing logic → Code is hard to maintain
Push streaming parsing down to BFF → Frontend only consumes the final data
Problem 3: Cross-Origin
localhost:5173 → localhost:3000 → Blocked by same-origin policy
Vite proxy config → Forward /api/* to BFF → Same origin achieved
3. Complete Dissection of the Server-Side Code — server.mjs
This is the most important file this time. The README notes define four steps for Node framework development: Install framework → Instantiate app → Listen on port → Define routes. Let's go through the code step by step.
3.1 Secure Loading of Environment Variables
import * as dotenv from 'dotenv';
// node's most common and simple development framework
// vite starts the http server, serving the frontend at 5173
// 5173 frontend -> 3000 BFF backend -> deepseek
// Frontend sends requests to the BFF layer to enjoy services web server backend private server status 3000
// http server
Line-by-line comment analysis:
import * as dotenv from 'dotenv':dotenvis the most commonly used environment variable management library in the Node.js ecosystem. It reads key-value pairs from the.envfile and injects them into theprocess.envobject. Why not use Vite'simport.meta.env? Because server.mjs is not compiled by Vite — it is an independent Node.js process, run directly vianode server.mjs. Vite's environment variable injection mechanism has no effect on it.// node's most common and simple development framework: This refers to Express. Express is the most successful web framework in Node.js history — not because it's the most powerful (Koa, Fastify are better in some aspects), but because it is the simplest and has the richest ecosystem. For a lightweight scenario like a BFF layer, simplicity is precisely the most important thing.// vite starts the http server, serving the frontend at 5173: The architecture of two ports, two processes::5173 (Vite Dev Server) :3000 (Node BFF Server) ┌──────────────────┐ ┌──────────────────┐ │ Frontend static │ │ API routes │ │ assets │ │ /stream │ │ HMR WebSocket │ │ LLM API calls │ │ Proxy │ │ Streaming data │ │ Vue SFC compile │ │ processing │ └──────────────────┘ └──────────────────┘ ↑ ↑ npm run dev node server.mjs// 5173 frontend -> 3000 BFF backend -> deepseek: This is the complete request chain:Vue component fetch('/api/stream?prompt=...') │ ▼ Vite Dev Server (:5173) Intercepts /api/* requests │ ▼ (proxy forward) BFF Express Server (:3000) Holds the API Key, forwards the request │ ▼ DeepSeek API (api.deepseek.com) Returns SSE stream// Frontend sends requests to the BFF layer to enjoy services web server backend private server status 3000: The BFF plays the role of a "service provider" here — unlike a traditional backend that serves all clients, it is specifically and exclusively serving this one frontend project. That's why the comment says it's in a "private server status" — a private, exclusive service.
import express from 'express';
// make our key more secure
// pure frontend, right-click view source,
// fetch -> bff (apiKey)
dotenv.config({
path: ['.env.local', '.env']
});
import express from 'express': The core module of the Express framework. It is a minimalist web framework that only provides three core capabilities: routing, middleware, and request/response handling. Everything else is supplemented by the community middleware ecosystem. For a "thin layer" scenario like BFF, Express's minimalist philosophy is a perfect match.// make our key more secureand// pure frontend, right-click view source: This is the core "unsolved problem" from the previous tutorial. In the previous demo, the API Key was written in the.env.localfile. Although it wasn't in the Git repository, Vite compiles environment variables prefixed withVITE_into the client-side JS bundle. Anyone can see your API Key through the browser's F12 → Sources panel. After moving the Key to the BFF layer, it only exists in the server process's memory and the.envfile, completely invisible to the browser side.// fetch -> bff (apiKey): The new request model. The frontend's fetch no longer carries the API Key (it doesn't need to know the Key at all). Instead, it sends the request to the BFF, which attaches the Key on the server side and forwards it to the DeepSeek API.dotenv.config({ path: ['.env.local', '.env'] }): Note that an array['.env.local', '.env']is passed here. This is dotenv's priority loading mechanism — it tries to load.env.local(local override config) first, and if it doesn't exist, loads.env(default config). This is consistent with Vite's environment variable loading order.
Environment variable loading priority:
.env.local → .env → process.env (system environment variables)
(highest priority) (second) (fallback)
Comparison of Key storage locations:
Previous article (pure frontend): .env.local → Vite compile → bundled into JS → visible via F12 ❌
This article (BFF architecture): .env.local → dotenv load → process.env → only visible on server ✅
3.2 Create Server Instance and Define Routes
const app = express(); // server app
const port = 3000;
// route
app.get('/', (req, res) => {
// continuously stream output
res.send('Hello World!'); // one-time send
})
const app = express(): Theexpress()factory function returns an application instance. Thisappobject is the "skeleton" of our entire BFF service — all subsequent routes and middleware are mounted on it.const port = 3000: Choosing port 3000 is a convention in the Node.js community (just like Vite defaults to 5173, React to 3000). Later, the proxy target in vite.config.js is alsolocalhost:3000; both sides must be consistent.// route: Route is the most core concept of a web framework. It defines "what handler function should be executed when a user accesses a certain URL". Express's route format isapp.METHOD(PATH, HANDLER).app.get('/', ...): Defines a GET request route with the path/(root path). This is the simplest "Hello World" route — openinghttp://localhost:3000/in a browser will showHello World!. Its existence is not a business requirement, but a "health check" endpoint — to confirm whether the BFF service has started normally.// continuously stream output: Pay attention to this comment! It hints that the/route could actually be streaming.res.send()is a one-time send (equivalent toresponse.json()), but if you switch to theres.write()+res.end()pattern, you can achieve streaming output. The/streamroute later will use this.res.send('Hello World!'); // one-time send:res.send()is Express's response sending method. It automatically sets the Content-Type and sends a complete response. The comment emphasizes "one-time" to contrast with the "chunk-by-chunk sending" of streaming output.
3.3 The BFF Route for Streaming Output — The Core
// streaming output bff layer, let the frontend call it
app.get('/stream', async (req, res) => {
// prompt req parsing
// fetch deepseek stream:true
// llm
// console.log(req.query.request);
// res.json({
// prompt: req.query.prompt,
// })
const { prompt } = req.query;
const endpoint =
'https://api.deepseek.com/v1/chat/completions';
Line-by-line deep analysis:
// streaming output bff layer, let the frontend call it: This is the core philosophy of BFF — strip the complex streaming processing logic from the frontend component and push it down to the server side. The frontend only needs to send a fetch request, without caring about low-level details like ReadableStream, TextDecoder, and buffer truncation recovery.app.get('/stream', async (req, res) => { ... }): Defines an asynchronous route handler function. Note theasync— because it will internallyawait fetch()and other network operations. In Express 4.x, error handling for async routes requires manual try/catch (as we'll see later).// prompt req parsing: Thereq(request) object contains all the request information sent by the frontend.req.queryis the result of Express automatically parsing the URL query string — accessing/stream?prompt=Hello, thenreq.query.prompt === 'Hello'. The comment "req parsing" refers to extracting the prompt parameter from the request object.// fetch deepseek stream:true: When the BFF sends a request to the DeepSeek API, the parameterstream: trueremains unchanged — the BFF does not change the communication mode with the frontend; it just passes the stream through from the LLM server to the frontend. Of course, you could also choose to do a conversion here (like parsing the SSE stream and then sending it, but that would lose the real-time advantage of streaming).// llm: This comment marks the core responsibility of the BFF — it is the "agent" for the LLM API. The frontend doesn't know the LLM API's address, Key, or request format; it only knows the BFF's/streaminterface.// console.log(req.query.request): Commented-out debug code. Used during development to verify whether the parameters passed from the frontend were correct. It also shows that the author might have initially considered usingrequestas the parameter name, later changing it toprompt.// res.json({ prompt: req.query.prompt }): Also "remnants" of debug code. During the testing phase, after receiving the request, the BFF first echoes the prompt back to the frontend to confirm the communication link is working, before connecting to the real LLM call. This is a layer-by-layer verification debugging strategy — first confirm the frontend-to-BFF link works, then confirm the BFF-to-LLM link works, and finally connect the entire chain.const { prompt } = req.query: ES6 destructuring assignment, extracting thepromptproperty from thereq.queryobject. Equivalent toconst prompt = req.query.prompt.const endpoint = 'https://api.deepseek.com/v1/chat/completions': Note the/v1/in the endpoint path. The DeepSeek API is compatible with OpenAI's interface format — that's why the endpoint path looks exactly like OpenAI's/v1/chat/completions. This compatibility design allows developers to switch between different model providers using the same SDK.
3.4 Initiating a Streaming Request to the LLM
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization':
`Bearer ${process.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
stream: true,
messages: [{ role: 'user', content: prompt}]
})
})
console.log(response.body) // ReadableStream
} catch(err) {
}
})
Line-by-line analysis:
try { ... } catch(err) { ... }: Defensive programming.fetchcan throw exceptions due to network issues, DNS resolution failures, timeouts, etc. The current catch block is empty (indicating the code is still in development), but in a production environment, it must be handled: at least return a 500 status code and error message to the frontend, letting the user know it's a "server-side problem" rather than "the page is frozen".await fetch(endpoint, { method: 'POST', headers, body }): The BFF layer initiates a POST request to the DeepSeek API. This uses the built-infetchAPI from Node.js 18+ — exactly the same usage as fetch in the browser. This is a standardization trend in the Node.js community in recent years — previously requiring third-party libraries likenode-fetchoraxios, now it's natively supported. BFF uses native fetch instead of axios, zero dependencies leading to faster startup times and a smaller security vulnerability surface.Authorization: \Bearer ${process.env.VITE_DEEPSEEK_API_KEY}``: This is the key point of the security improvement. Note the comparison:Previous article (pure frontend): Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}` → import.meta.env → Vite compile-time replacement → exposed in client-side JS This article (BFF architecture): Authorization: `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}` → process.env → dotenv runtime injection → only exists in the Node.js processThe value of
process.envonly exists in the memory of the Node.js process and will not appear in any browser-visible code. The sameVITE_DEEPSEEK_API_KEYis read from the.env.localfile, but the reading method has changed from "compile-time injection into client code" to "runtime injection into the server process" — a qualitative leap in security.model: 'deepseek-v4-flash': Consistent with the previous article. The Flash version trades slightly lower output quality for faster response speed, suitable for streaming conversation scenarios.stream: true: Tells the DeepSeek API to return data token by token in SSE format.console.log(response.body) // ReadableStream: In Node.js,response.bodyis also aReadableStream<Uint8Array>. This is completely consistent with the behavior in the browser — this is the "isomorphic" nature of the Web Streams API: as long as the runtime supports this standard, the code behaves identically in the browser and Node.js. After the BFF gets this ReadableStream, it can:- Pass it directly through to the frontend — maintain the streaming characteristic, the frontend sees tokens in real-time
- Parse it first, then send — do the dirty work of SSE on the server side, the frontend directly gets the parsed text
The note says "abstract it, put it in the full-stack BFF layer", referring to the second option.
3.5 Starting the Server
app.listen(port, () => {
console.log(`Server started on port ${port}`);
})
// console.log(process.env.VITE_DEEPSEEK_API_KEY)
// llm request comes to bff
// backend is lightweight, just this one file, server side
// npm run dev vite service`
// node server.mjs runs the backend process
console.log('I am a BFF program hidden in a frontend project')
Line-by-line comment analysis:
app.listen(port, () => {...}): Express's startup method.listencreates an HTTP server and starts listening on the specified port (3000). The second parameter is a callback function — executed after the server successfully starts. It prints a confirmation message here, "Server started on port 3000" lets the developer know the BFF layer is ready.// console.log(process.env.VITE_DEEPSEEK_API_KEY): Commented-out debug code. Used during the development phase to confirm whether the API Key was correctly loaded by dotenv. Note: Never print the API Key in a production environment — logs might be collected, stored, and leaked. This comment itself is also a reminder of "security awareness".// llm request comes to bff: Emphasizes the BFF's responsibility — all LLM-related requests should be sent to the BFF, not directly from the frontend to the LLM API.// backend is lightweight, just this one file, server side: This is the essence of the BFF philosophy — it should be lightweight. No need for Controller, Service, Repository layers like a traditional backend, no complex dependency injection framework, no ORM. One file, a few routes, one core logic, that's enough. This lightweight nature allows frontend engineers to write and maintain it easily.// npm run dev vite serviceand// node server.mjs runs the backend process: Emphasizes that the two processes are independent and need to be started separately:Terminal window 1: npm run dev → Vite Dev Server (:5173) Terminal window 2: node server.mjs → BFF Express Server (:3000)In actual development, you can use
concurrentlyornpm-run-allto combine the two commands into one startup.console.log('I am a BFF program hidden in a frontend project'): A very distinctive line. It reveals an interesting characteristic of BFF — BFF code physically exists within the frontend project (in the same repository as Vue components, vite.config.js), but logically belongs to the backend. This is the meaning of "full-stack frontend" — writing both frontend UI and the backend that serves the frontend.
4. Frontend Code — The "Slimming Down" of App.vue
After pushing the streaming processing logic down to the BFF, the frontend code becomes noticeably thinner:
<script setup>
// one-time -> streaming frontend -> bff -> llm request
fetch('/api/stream?prompt=hello')
.then(res => res.json())
.then(data => {
console.log(data);
})
</script>
// one-time -> streaming frontend -> bff -> llm request: This comment draws the evolution trajectory of the entire architecture:
- "one-time" — the earliest way, frontend directly requests the LLM API,
stream: false, waits for all generation to complete and returns at once - "streaming" — the way implemented in the previous article, frontend directly requests the LLM API,
stream: true, frontend handles ReadableStream and SSE parsing itself - "frontend -> bff -> llm request" — the way in this article, frontend requests BFF, BFF requests LLM, frontend doesn't need to know any details of the LLM API
The current fetch in App.vue is still using .then() chaining (rather than async/await), indicating this is a transitional version — first verify the communication link from frontend to BFF is working (hence only console.log), then integrate the complete streaming processing logic.
The template and style sections remain consistent with the previous article — v-model="question" for two-way binding the input box, v-model="stream" to control the streaming toggle, {{ content }} to display the AI's answer. The document flow comment in CSS (/* Document flow is the basis of page layout, top to bottom, left to right, flow layout */) was already analyzed in detail in the previous article and won't be repeated.
5. Cross-Origin and Proxy — The "Transfer Station" of vite.config.js
5.1 Same-Origin Policy: The Browser's Security Line of Defense
The README notes describe the cross-origin problem like this:
"As long as the domain, port, or protocol (http/https) is different, when fetching, it's cross-origin, same-origin policy"
Same-Origin Policy is the browser's most core security mechanism. It stipulates: JavaScript in a page can only access HTTP resources that are same-origin with the current page. "Origin" is defined by three parts:
Origin = Protocol + Domain + Port
http://localhost:5173 ← Vite Dev Server (frontend page is here)
http://localhost:3000 ← BFF Express Server (API is here)
Ports are different (5173 ≠ 3000) → Cross-origin → Browser blocks fetch request → CORS error
Without the same-origin policy, if you opened the website evil.com in your browser, it could use fetch to request bank.com's API (carrying the cookies you previously logged in with), and then steal your bank account information.
5.2 Vite Proxy: The "Legitimate Transfer" in the Development Phase
The README notes propose a solution:
"vite.config.js solution: change the request address to /api/stream, /api marks the request as a backend API interface, no more cross-origin, but 502. vite project proxy config, proxy the request and forward it out"
This note accurately describes a two-step troubleshooting process. Let's look at it against the vite.config.js code:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
// use vite to solve cross-origin
// when a request is sent, vite will intervene
server: {
// proxy request
proxy: {
// frontend wants to go to the backend, requests starting with /api
'/api': {
// cross-origin, under the browser environment, the security of same-origin policy
target: 'http://localhost:3000',
secure: false,
// /api/stream
rewrite: path => path.replace(/^\/api/, '')
}
}
}
})
Line-by-line comment deep analysis:
// use vite to solve cross-origin: This isn't Vite's "unique skill" — almost all modern frontend build tools (Webpack Dev Server, Vite, Turbopack) have built-in proxy functionality. The principle is the same: start a lightweight HTTP proxy server within the Dev Server, intercept requests for specific paths, and forward them to the real backend.// when a request is sent, vite will intervene: This describes the middleman role of the Vite Dev Server. When the frontend code executesfetch('/api/stream'):- The request first reaches the Vite Dev Server (:5173), because the frontend page was loaded from here (same-origin)
- Vite checks if the request path matches the proxy rules
- Match successful → Vite acts as a proxy, forwarding the request to the target address
- The backend's response → Vite forwards it back to the browser
// proxy request: The essence of a proxy is a "middleman" — the client does not directly connect to the server, but connects indirectly through the proxy. The proxy here is a Forward Proxy, Vite requests the backend on behalf of the browser.// frontend wants to go to the backend, requests starting with /api: The/apiprefix is a convention over configuration design. All backend API requests start with/api. The benefits of this are:- You can identify at a glance which requests are API calls and which are static resource requests
- The proxy rule only needs to match the single prefix
/api - The rewrite rule is also very simple — just remove the
/apiprefix
target: 'http://localhost:3000': The proxy's target address — which is the BFF Express Server's address. Note it'slocalhostnot127.0.0.1— although they point to the same address on most systems, Node.js's DNS resolution behavior is OS-dependent, andlocalhostis more universal.// cross-origin, under the browser environment, the security of same-origin policy: This comment explains why a proxy is necessary — the same-origin policy is enforced by the browser and only takes effect in the browser environment. Communication between servers (BFF → DeepSeek API) is not restricted by the same-origin policy. So the proxy's idea is: turn a cross-origin request into a same-origin request — the browser requests the same-origin Vite Dev Server, and the Vite Dev Server then requests the backend BFF as a "server".secure: false: Allows the proxy to use self-signed certificates when connecting to an HTTPS server. In a local development environment, the backend is usually HTTP (http://localhost:3000), so this option isn't really needed. But if the backend is HTTPS and the certificate is untrusted,secure: falseallows the proxy to work normally.// /api/stream: Gives a concrete example — the frontend requests/api/stream, which becomes the backend's/streamafter proxy and rewrite.rewrite: path => path.replace(/^\/api/, ''): The path rewrite rule. This is a pure function — input the original path, output the rewritten path:Input: /api/stream?prompt=hello Regex: /^\/api/ matches the "/api" at the beginning of the path Replacement: empty string Output: /stream?prompt=hello ← This is the actual path sent to the BFF (localhost:3000)Why remove
/api? Because the BFF's backend route is defined as/stream, not/api/stream./apiis a "marker" for the Vite proxy, telling Vite "this request needs to be proxied"; the real BFF doesn't need this prefix.
Complete request chain (step by step):
1. Vue component executes
fetch('/api/stream?prompt=Hello')
2. Browser network stack
Target URL: http://localhost:5173/api/stream?prompt=Hello
Same-origin with the current page (host: localhost, port: 5173)
→ Same-origin policy: allowed ✅
3. Vite Dev Server (:5173)
Receives GET /api/stream?prompt=Hello
Checks proxy rules: path starts with /api → match!
Executes rewrite: /api/stream → /stream
target: http://localhost:3000
→ Forwards request to http://localhost:3000/stream?prompt=Hello
4. BFF Express Server (:3000)
Receives GET /stream?prompt=Hello
Route match: app.get('/stream', ...)
→ Executes handler function
5. BFF → DeepSeek API
Attaches Authorization Header (reads Key from process.env)
POST https://api.deepseek.com/v1/chat/completions
body: { model: 'deepseek-v4-flash', stream: true, messages: [...] }
6. DeepSeek API → BFF → Vite → Browser
SSE streaming response passed back layer by layer
6. Analysis of the "502" Problem in the README Notes
There's a section in the README about the debugging process that's particularly worth expanding:
"change the request address to /api/stream, /api marks the request as a backend API interface, no more cross-origin, but 502"
502 Bad Gateway is one of the HTTP status codes, meaning "Gateway Error" — the proxy server (here, the Vite Dev Server) received an invalid response from the upstream server (the BFF Express Server).
When encountering a 502 during development, the troubleshooting approach is:
Frontend fetch('/api/stream')
│
▼
Vite Proxy (:5173) ← Receives request
│
▼
Tries to forward to http://localhost:3000/stream
│
▼
Is the BFF started?
├── Not started → Connection refused → Vite returns 502
└── Started → Process normally → Return response
Common causes of 502:
- BFF service not started — Only ran
npm run dev(Vite), forgot to runnode server.mjs(BFF) - Port conflict — Port 3000 is occupied by another process
- Rewrite rule written incorrectly — For example, after path rewriting, it becomes a route that doesn't exist on the BFF
The README note "no more cross-origin, but 502" indicates that the same-origin policy has been resolved (Vite proxy turned /api/* into a same-origin request), and the problem lies in the connection between the proxy and the BFF. This is a positive development process — solve cross-origin first, then solve connectivity.
7. Architecture Panorama: Comparison of Three Chains
Let's put the previous article (pure frontend calling LLM), this article's current code (BFF forwarding but not processing the stream), and the ideal complete BFF solution into one diagram for comparison:
┌────────────────────────────────────────────────────────────────────┐
│ Comparison of Three Architecture Solutions │
├────────────────────────────────────────────────────────────────────┤
│ │
│ Solution 1: Pure Frontend Direct Call (Previous Article) │
│ ┌──────────┐ fetch + Key + SSE Parse ┌──────────┐ │
│ │ Vue │ ──────────────────────→ │ DeepSeek │ │
│ │ Frontend │ ← SSE Binary Stream ──── │ API │ │
│ │ :5173 │ └──────────┘ │
│ └──────────┘ │
│ Problems: Key exposed, bloated frontend │
│ │
├────────────────────────────────────────────────────────────────────┤
│ │
│ Solution 2: BFF Forwarding (Current Code in This Article) │
│ ┌──────────┐ fetch ┌──────────┐ fetch+Key ┌──────────┐ │
│ │ Vue │ ─────────→ │ Node BFF │ ──────────→ │ DeepSeek │ │
│ │ Frontend │ │ :3000 │ │ API │ │
│ │ :5173 │ └──────────┘ └──────────┘ │
│ └──────────┘ │
│ Key secure ✅ Frontend slimming in progress (code still iterating) │
│ │
├────────────────────────────────────────────────────────────────────┤
│ │
│ Solution 3: Complete BFF Solution (Next Step Goal) │
│ ┌──────────┐ fetch ┌──────────┐ fetch+Key ┌──────────┐ │
│ │ Vue │ ─────→ │ Node BFF │ ─────────→ │ DeepSeek │ │
│ │ Frontend │ │ │ │ API │ │
│ │ Only │ ← ─ ─ │ Parse SSE│ ← ─ ─ ─ ─ │ │ │
│ │ receives │ SSE │ Encode/ │ Binary │ │ │
│ │ plain │ │ Decode │ Stream │ │ │
│ │ text │ │ buffer │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ Key secure ✅ Frontend minimal ✅ Stream processing in BFF ✅ │
│ │
└────────────────────────────────────────────────────────────────────┘
8. Summary: From "It Works" to "Engineered"
In the previous article, we implemented streaming output from scratch — the frontend directly leveraged the LLM API, using ReadableStream + buffer + SSE parsing to render tokens onto the page character by character. It proved that this thing can be done.
This article takes a step forward from "can be done" towards "done well":
| Dimension | Previous Article (Pure Frontend) | This Article (Introducing BFF) |
|---|---|---|
| API Key Security | ❌ Exposed in client JS | ✅ Only exists in server process |
| Frontend Complexity | ❌ Streaming parse + UI mixed together | Gradually simplifying |
| Cross-Origin Problem | N/A (direct to LLM) | ✅ Vite proxy perfectly solves it |
| Architecture Scalability | ❌ Change frontend for every new feature | ✅ BFF handles uniformly, frontend only cares about data |
| Isomorphic Capability | N/A | ✅ Node/Browser share Web Streams API |
After reading these two articles, you should have a clear evolution roadmap in your mind:
- Phase 1: Frontend directly connects to LLM → Understand the underlying principles of binary streams, SSE, and buffer mechanisms
- Phase 2: Introduce the BFF layer → Consolidate security, complexity, and cross-origin issues to the server side
- Phase 3 (Next step): BFF fully processes streaming data → Frontend only receives parsed plain text, code is as clean as calling a regular API
Engineering is not achieved in one step — it's built layer by layer. Each layer solves a specific problem, and each layer does not introduce unnecessary complexity. First make the code "runnable", then make the code "run elegantly".
This article and the previous one, "Stream is All You Need — From Binary Bytes to the Agent Era", form a complete streaming output series. The previous article focuses on underlying principles (binary encoding, SSE protocol, buffer truncation recovery, Vue reactive rendering), while this article focuses on engineering architecture (BFF layer design, API Key security, Vite Proxy, cross-origin solutions). Together, they cover the complete knowledge chain from underlying bytes to upper-level architecture.