跪拜 Guibai
← Back to the summary

Stop Exposing API Keys: A BFF Pattern for AI Streaming Apps

Essential for Full-Stack Frontend Engineers: Building a Secure AI Streaming Application with BFF Architecture

Abstract

When building AI applications, directly calling the LLM API from the frontend faces three major problems: API Key exposure, complex SSE streaming data parsing, and cross-origin restrictions. This article builds a BFF (Backend For Frontend) architecture from scratch using Vue 3 + Vite + Express, securely hiding the API Key on the server side, handling streaming data in the Node layer, and allowing the frontend to get clean data with a single line of fetch. After reading, you will understand why a BFF is needed, how the Vite proxy solves cross-origin issues, the essential differences between browser and Node processes, and how to handle the complete chain from the frontend to the AI interface by yourself.


1. The Starting Point: Three Fatal Flaws of Frontend Directly Connecting to LLMs

When building an AI chat application, if the frontend directly calls the DeepSeek (or OpenAI) API, it immediately hits three walls:

Fatal 1: API Key Exposure

// Frontend code — anyone can see it by pressing F12
fetch('https://api.deepseek.com/v1/chat/completions', {
  headers: { 'Authorization': 'Bearer sk-xxxxxxxx' }
})

The Key is written in the JS, and a user can right-click to view the source and take it. It's only a matter of time before someone steals it and your API bill explodes.

Fatal 2: Complex SSE Streaming Data Parsing

The LLM returns a raw SSE (Server-Sent Events) stream:

data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]

The frontend needs ReadableStreamgetReader() → manual decoding → splitting by line → parsing the data: prefix → extracting JSON → concatenating strings. The code is messy and long, and it has to be rewritten for every client (Web, iOS, Android).

Fatal 3: Cross-Origin Blocking

The frontend runs on localhost:5173 (Vite), and the BFF runs on localhost:3000 (Express). Different ports mean cross-origin, and the browser directly blocks the request, preventing it from being sent at all.


2. The Solution: BFF Architecture

BFF (Backend For Frontend) is a Node middle layer placed between the frontend and backend, specifically serving the frontend.

Without BFF:   Frontend ──→ LLM (Key exposed / complex stream parsing)
With BFF:      Frontend ──→ Node BFF ──→ LLM (Key hidden in BFF / stream processed in Node layer)

This BFF is written by the frontend engineer themselves, without waiting for backend colleagues. With a BFF:

Problem How it's solved
Key Security Key stored in .env file, only on the Node server, never seen by the frontend
SSE Parsing Node layer handles the dirty work, frontend only gets the final result
Cross-Origin Vite proxy forwards requests; communication between Node processes is not subject to browser same-origin policy

This is the core capability of a "Full-Stack Frontend Engineer": handling the complete chain from the browser UI to the BFF to the AI interface by yourself.


3. Project Structure Overview

stream-bff/
├── index.html              ← Browser entry point
├── package.json            ← Project dependency description
├── vite.config.js          ← Vite build config + cross-origin proxy
├── server.mjs              ← BFF core! Express server
├── .env                    ← API Key hiding place (do not commit to Git)
├── src/
│   ├── main.js             ← Vue application startup
│   └── App.vue             ← Root component (UI + sending requests)

Division of labor among three processes:

Process Startup Method Port Responsibility
Vite npm run dev 5173 Compile Vue, serve page files, proxy API requests
Express node server.mjs 3000 Hide Key, call LLM, process streaming data, return to frontend
Browser Opened by user Render page, execute Vue code, display AI reply

4. Browser Layer: How the Page Loads

File relay chain:

index.html
  │  <div id="app"></div>            ← Vue mount point, initially empty
  │  <script src="/src/main.js">    ← Load JS entry
  ▼
main.js
  │  import { createApp } from 'vue'
  │  import App from './App.vue'
  │  createApp(App).mount('#app')   ← Insert App component into div#app
  ▼
App.vue
  │  <script setup> — sends fetch request
  │  <template>     — UI structure
  │  <style>        — styles
  ▼
Page rendering complete, user sees the interface

Advantage of Vue Single-File Components (.vue): Template, logic, and styles are encapsulated in one file, without being scattered across multiple files. Vite uses the @vitejs/plugin-vue plugin to compile .vue into JavaScript that the browser can execute.


5. Solving Cross-Origin: Vite Proxy

What is cross-origin? The browser's same-origin policy: if protocol, domain, or port is different, the request is blocked.

Vite  :5173
Express :3000   → Different ports → Cross-origin!

Solution — Configure proxy in vite.config.js:

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api': {                                    // Intercept all requests starting with /api
        target: 'http://localhost:3000',           // Forward to Express
        secure: false,                             // Don't verify HTTPS in dev environment
        rewrite: (path) => path.replace(/^\/api/, '')  // /api/stream → /stream
      }
    }
  }
})

The complete journey of a request:

Browser fetch('/api/stream?prompt=你好')
  │  Browser auto-completes: http://localhost:5173/api/stream?prompt=你好
  │  Same origin! (5173→5173) No cross-origin!
  ▼
Vite :5173 receives the request
  │  /api prefix matches proxy rule
  │  rewrite: /api/stream → /stream
  │  target: Forward to http://localhost:3000/stream
  │  This step is Node↔Node, doesn't go through the browser, no cross-origin check!
  ▼
Express :3000 receives GET /stream?prompt=你好

Why doesn't the proxy trigger cross-origin? Cross-origin checks only happen in the browser. Vite and Express are both Node processes; processes communicate directly, and the browser has no say. It's like needing to go through customs when leaving the country (browser), but colleagues inside the company can transfer files without it.

What is the role of /api? An agreed-upon prefix marker. Starts with /api → this is a backend API request, Vite forwards it for you. Not /api → frontend static resource, Vite handles it itself.


6. BFF Layer: server.mjs Explained

// ① Load environment variables
import * as dotenv from 'dotenv'
dotenv.config({ path: ['.env.local', '.env'] })   // Read .env, Key goes into process.env

// ② Import Express framework
import express from 'express'

// ③ Instantiate server
const app = express()    // Create app instance
const port = 3000        // Define port

// ④ Health check route
app.get('/', (req, res) => {
  res.send('Hello World!')      // Test if service is running
})

// ⑤ Core business route — frontend calls this
app.get('/stream', async (req, res) => {
  const { prompt } = req.query  // Extract user input from URL: ?prompt=你好

  const endpoint = 'https://api.deepseek.com/v1/chat/completions'

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.VITE_DEEPSEEK_API_KEY}` // Key securely read from .env
      },
      body: JSON.stringify({
        model: 'deepseek-v4-flash',
        stream: true,    // Enable streaming return
        messages: [{ role: 'user', content: prompt }]
      })
    })
    // response.body is a ReadableStream — a pipe interface, still needs reading and processing
  } catch (err) {
    // Error handling
  }
})

// ⑥ Start server
app.listen(port, () => {
  console.log(`Server started on port ${port}`)
})

Key concepts:

What is a port? A port is a "room number" assigned by the operating system to a process. app.listen(3000) applies to the OS for room number 3000; afterwards, all network requests sent to localhost:3000 are automatically forwarded by the OS to this Express process.

What is a route? A mapping between request paths and functions:

GET /        → returns "Hello World!"
GET /stream  → calls DeepSeek and returns the result
GET /abc     → not found, 404

What are req and res? Two objects automatically passed in by Express, packaged for you every time a request comes in:

Object Direction Role Common properties/methods
req (Request) Frontend → Backend All request info .query (URL params), .method (GET/POST), .headers
res (Response) Backend → Frontend Reply toolkit .send(), .json(), .status()

Analogy: req is the earpiece (hearing what the other party says), res is the microphone (you reply through it).


7. Frontend Layer: How App.vue Requests the BFF

<script setup>
fetch('/api/stream?prompt=hello')    // Relative path, auto-completed to 5173, no cross-origin
  .then(res => res.json())           // Read response body, parse JSON
  .then(data => console.log(data))   // Get the real data
</script>

Why are there two .then()?

fetch()
  → Returns a Response object (envelope: status code + headers + body stream)
     .then(res => res.json())        ← Open the envelope, read the body
       → Returns the parsed JS object
         .then(data => console.log()) ← Get the content

res is the delivery package, res.json() opens it, data is the thing inside.

What is ?prompt=hello? ? is the separator between path and parameters — before it is where to go, after it is what data to bring. Express automatically parses ?prompt=hello into req.query = { prompt: "hello" }.


8. Complete Data Flow (Everything that happens after the user clicks "Send")

① User inputs "你好", clicks submit
     │
② App.vue executes fetch('/api/stream?prompt=你好')
   → Browser completes to http://localhost:5173/api/stream?prompt=你好
   → Same origin! No cross-origin!
     │
③ Vite :5173 receives the request
   → /api prefix matches proxy rule
   → rewrite: /api/stream → /stream
   → Node internally forwards to Express :3000/stream?prompt=你好
     │
④ Express :3000 receives GET /stream
   → Route matches app.get('/stream', fn)
   → req.query.prompt → "你好"
   → fetch DeepSeek, with API Key from .env
     │
⑤ DeepSeek returns SSE streaming data
   → BFF parses the stream, extracts content
   → res.json() returns to Vite
     │
⑥ Vite returns the response as-is to the browser
     │
⑦ App.vue gets the data → renders to the page
   → User sees the AI reply

9. Core Knowledge Points Connected

Concept One-sentence explanation
BFF A Node middle layer customized for the frontend, hides Keys, processes streams, returns clean data
SSE Server unidirectional push technology, LLMs use this to stream AI replies
Port The "room number" of a process in the operating system
Instantiation express() = turning a blueprint into a concrete object that can work
Route The mapping between URL paths and functions
Cross-Origin Browser security policy, blocks if protocol/domain/port differs
Vite Proxy Bypasses cross-origin during development, Node↔Node communication is not subject to checks
req Request object, all info sent from the frontend (earpiece)
res Response object, the toolkit for the backend to reply to the frontend (microphone)
.env Stores sensitive configuration (API Key), not committed to Git
res.json() Converts a JS object into a JSON string and returns it to the frontend
Single-File Component Vue's .vue file, encapsulates template + logic + styles together

10. Summary

The core competitive advantage of a Full-Stack Frontend Engineer is independently solving problems across the entire chain. The value of this BFF architecture lies in:

  1. Security — Key is hidden in .env, users never see it
  2. Simplicity — Frontend only calls fetch, the dirty work of SSE parsing is left to Node
  3. Independence — Change the interface whenever you want, no waiting for backend colleagues to schedule it
  4. Reusability — Write the BFF once, Web/iOS/Android can all call it

The code in this article currently completes the request chain setup from the browser to the BFF to the LLM. The next step is to read the ReadableStream returned by DeepSeek in server.mjs, reading and forwarding it to the frontend in real-time, achieving true streaming conversation. This will be detailed in a subsequent article.


Keywords: BFF, SSE, Vite, Express, Cross-Origin, Full-Stack Frontend, Streaming Output, AI Application Architecture