生产环境部署:Fastify 静态服务 + SPA fallback
本文面向:想了解生产环境前后端合并部署方案的开发者。
预计阅读时间:10 分钟
最终效果:理解 Fastify 静态文件服务、SPA fallback、CORS 配置和优雅关闭的完整实现。
在开发阶段,前端运行在 Vite 开发服务器上(端口 13721),后端运行在 Fastify 上(端口 3721),两者通过代理通信。但到了生产环境,我们不需要两个服务器——Fastify 可以同时承担 API 服务和静态文件托管的职责。本文将深入分析 ChatCrystal 如何实现这一架构,以及其中涉及的关键技术点。
开发 vs 生产环境的差异
先理解两个环境的核心区别:
开发环境:前后端分离,各自独立运行。
浏览器 → Vite (localhost:13721) → 代理 /api → Fastify (localhost:3721)Vite 的vite.config.ts中配置了代理规则:
server:{port:13721,host:'127.0.0.1',proxy:{'/api':'http://localhost:3721',},},浏览器请求http://localhost:13721/api/notes时,Vite 会将请求转发到http://localhost:3721/api/notes。前端开发者可以热更新 UI,后端开发者可以独立重启服务,互不干扰。
生产环境:前后端合并到同一个端口。
浏览器 → Fastify (localhost:3721) ├── /api/* → API 路由处理 └── 其他路径 → 静态文件 / SPA fallback用户只需要访问一个地址,所有请求都由 Fastify 统一处理。
构建流程:npm run build
生产部署的第一步是构建。ChatCrystal 的package.json中定义了构建命令:
"build":"npm run build -w server && npm run build -w client"这个命令依次执行两个工作空间的构建:
npm run build -w server— 将 TypeScript 编译为 JavaScript,输出到server/dist/目录npm run build -w client— Vite 将 React 应用打包为静态资源,输出到client/dist/目录
构建完成后,client/dist/目录会包含index.html、CSS 文件、JavaScript bundle 以及各种静态资源。这些就是生产环境需要托管的文件。
注意构建顺序:先构建 server,再构建 client。这是因为 server 的类型定义可能被 client 引用(通过@chatcrystal/shared),确保依赖关系正确。
静态文件服务:@fastify/static
Fastify 通过@fastify/static插件来托管静态文件。在server/src/index.ts中,注册逻辑如下:
importfastifyStaticfrom'@fastify/static';import{resolve}from'node:path';import{existsSync}from'node:fs';constcandidatePaths=[resolve(import.meta.dirname,'../../client/dist'),resolve(import.meta.dirname,'../../../../client/dist'),];constclientDist=candidatePaths.find((p)=>existsSync(p))??candidatePaths[0];if(existsSync(clientDist)){awaitapp.register(fastifyStatic,{root:clientDist,prefix:'/',wildcard:false,});}这段代码有几个值得学习的设计点:
多路径探测。为什么需要两个候选路径?因为 ChatCrystal 有两种运行方式:
- 源码直接运行(tsx):
import.meta.dirname指向server/src/,所以../../client/dist是正确路径 - 编译后运行(node):
import.meta.dirname指向server/dist/server/src/,需要向上回溯四级才能到达项目根目录
candidatePaths.find()会依次检查哪个路径存在,找不到就回退到第一个。这种"探测式"的路径解析让代码在不同环境下都能正确工作。
prefix: ‘/’表示静态文件挂载在根路径。访问/index.html会从client/dist/index.html提供文件。
wildcard: false是一个关键配置。默认情况下,@fastify/static会注册一个通配符路由来处理所有静态文件请求。设为false后,它只处理明确匹配的文件请求,不会拦截其他路由。这让我们可以自定义 404 处理逻辑——也就是 SPA fallback。
SPA fallback:为什么需要、怎么实现
SPA(Single Page Application)的核心问题在于:React Router 路由是前端实现的,服务器并不知道/notes/123对应什么文件。
假设用户访问http://localhost:3721/notes/123:
- 服务器上不存在
client/dist/notes/123这个文件 - 如果返回 404,页面就会白屏
- 正确的做法是返回
index.html,让 React Router 接管路由
这就是 SPA fallback 的作用——当请求的路径没有匹配到任何静态文件或 API 路由时,返回index.html,让前端路由器决定显示什么内容。
ChatCrystal 的实现如下:
app.setNotFoundHandler((req,reply)=>{if(req.url.startsWith('/api/')){reply.status(404).send({success:false,error:'Not Found'});}else{reply.sendFile('index.html');}});setNotFoundHandler是 Fastify 提供的全局 404 处理器。当没有任何路由匹配时,这个函数会被调用。逻辑很简单:
- 如果路径以
/api/开头 → 这是一个 API 请求,返回 JSON 格式的 404 错误 - 否则 → 这是一个页面请求,返回
index.html
API 404 vs 页面 404 的区分
为什么要区分 API 404 和页面 404?因为两者的消费者不同。
API 404:被 JavaScript 代码消费。前端的fetch调用期望收到 JSON 响应,以便解析错误信息。如果 API 返回 HTML,前端代码会因为 JSON 解析失败而崩溃。
{"success":false,"error":"Not Found"}前端可以检查response.ok或解析success字段,展示友好的错误提示。
页面 404:被浏览器消费。用户在地址栏输入一个不存在的路径,应该看到应用界面(带有导航栏、侧边栏),而不是一个空白的错误页。返回index.html后,React Router 会匹配到最近的路由或者显示一个 404 页面组件。
这种区分是所有前后端分离项目的通用实践。如果你在构建自己的项目,记住这个模式:API 返回 JSON,页面返回 HTML。
CORS 配置:开发 vs 生产
CORS(Cross-Origin Resource Sharing)是浏览器的同源策略机制。当请求的协议、域名或端口不同时,浏览器会限制跨域请求。
ChatCrystal 的 CORS 配置非常简洁:
awaitapp.register(cors,{origin:true});origin: true表示将请求的Origin头作为响应的Access-Control-Allow-Origin返回,等同于允许所有来源。
在开发环境中这是必要的:前端运行在localhost:13721,后端运行在localhost:3721,端口不同,属于跨域。
在生产环境中,前后端共享同一个端口,同源策略不会生效,CORS 配置实际上不会被触发。但保留它没有副作用,而且如果你将来需要从其他域名访问 API(比如移动端应用),这个配置依然有用。
如果你对安全性有更高要求,生产环境中可以限制 origin 为特定域名:
awaitapp.register(cors,{origin:process.env.NODE_ENV==='production'?'https://your-domain.com':true,});端口和主机配置
服务器启动时的网络配置:
constport=options?.port??appConfig.port;// 默认 3721consthost=options?.host??'0.0.0.0';awaitapp.listen({port,host});端口:默认 3721,可以通过options.port参数或配置文件覆盖。这个设计让 Electron 可以传入自定义端口。
主机:0.0.0.0意味着监听所有网络接口。这在生产环境中很重要——如果设置为127.0.0.1(仅本机),那么其他设备(如同一局域网内的手机)将无法访问服务。
ChatCrystal 还支持通过环境变量覆盖端口:
PORT=8080npmstart这在容器化部署(Docker)或需要与其他服务共存时非常有用。
优雅关闭:watcher -> DB -> HTTP
服务器关闭时,不能直接process.exit(),否则可能导致数据丢失。ChatCrystal 实现了有序的优雅关闭(graceful shutdown):
asyncfunctionshutdown(){console.log('[Server] Shutting down...');awaitwatcher.close();// 第一步:停止文件监听closeDatabase();// 第二步:保存并关闭数据库awaitapp.close();// 第三步:关闭 HTTP 服务器}为什么要按这个顺序?
第一步:停止 watcher。文件监听器(chokidar)会在后台持续扫描文件变化。如果先关闭数据库,watcher 触发的导入操作会因为数据库不可用而报错。所以先停止 watcher,确保不会有新的写入请求。
第二步:关闭数据库。closeDatabase()会执行以下操作:
exportfunctioncloseDatabase():void{stopAutoSave();// 停止 30 秒自动保存定时器if(db){saveDatabase();// 将内存中的数据写入磁盘db.close();// 释放数据库连接db=null;}}sql.js 是内存数据库,所有修改都保存在 RAM 中,通过定期saveDatabase()写入磁盘文件。关闭前必须执行一次最终保存,否则会丢失最近 30 秒内的修改。
第三步:关闭 HTTP 服务器。等待所有正在处理的请求完成后,关闭 TCP 连接。放在最后是因为关闭过程中可能还有请求依赖数据库读取。
在独立模式下,shutdown 通过信号触发:
process.on('SIGINT',handle);// Ctrl+Cprocess.on('SIGTERM',handle);// kill 命令或容器停止前后端同端口部署的优势
ChatCrystal 将前后端合并到同一个端口,这种架构有几个实际好处:
部署简单。只需要暴露一个端口,不需要配置反向代理(nginx)来分别路由静态文件和 API 请求。对于个人工具类项目,这大大降低了运维复杂度。
避免 CORS 问题。同源请求不受浏览器跨域限制,省去了复杂的 CORS 配置和调试。
Electron 兼容。Electron 桌面应用内部嵌入了同一个 Fastify 服务器。如果前后端分离,Electron 需要同时管理两个进程,复杂度会显著增加。
exportasyncfunctioncreateServer(options?:{port?:number;host?:string;}):Promise<ServerInstance>{// ... 返回 app 实例和 shutdown 函数}createServer被设计为可导出的函数,Electron 主进程可以直接调用它来启动服务器,无需通过子进程。
常见部署问题
问题 1:静态文件 404
症状:访问页面返回 JSON 格式的 404,或者显示空白页。
原因:client/dist目录不存在或路径不对。
排查:检查构建是否成功,确认client/dist/index.html文件存在。查看服务器启动日志中的[Server] Serving frontend from信息。
问题 2:页面刷新后 404
症状:首页正常,点击链接跳转正常,但手动刷新非根路径的页面时返回 404。
原因:SPA fallback 没有生效。可能是wildcard: true(默认值)拦截了请求,导致setNotFoundHandler不被触发。
排查:确认@fastify/static注册时设置了wildcard: false。
问题 3:API 返回 HTML 而不是 JSON
症状:前端 fetch 调用收到 HTML 内容,JSON 解析失败。
原因:API 路径没有正确注册,请求落入了 SPA fallback。
排查:确认 API 路由在静态文件注册之前注册。检查请求 URL 是否正确以/api/开头。
问题 4:端口被占用
症状:启动时报EADDRINLE错误。
排查:lsof -i :3721(Linux/Mac)或netstat -ano | findstr 3721(Windows)查看占用进程。可以通过PORT环境变量切换端口。
下一步
了解了生产环境部署后,你可以继续探索:
- Electron 打包:如何将同一套服务器嵌入桌面应用,实现零配置的本地体验
- Docker 容器化:将构建流程和运行环境打包为镜像
- 反向代理配置:当需要域名、HTTPS、负载均衡时,如何在 Fastify 前面加 nginx
- 性能优化:静态资源缓存策略、Gzip 压缩、数据库连接池
ChatCrystal 的部署架构虽然简单,但涵盖了生产部署的核心概念。理解这些原理后,你可以将同样的模式应用到任何前后端分离的项目中。
遇到问题可以私信我
项目地址:github.com/ZengLiangYi/ChatCrystal
