生产环境部署:Fastify 静态服务 + SPA fallback

0 阅读9分钟

本文面向:想了解生产环境前后端合并部署方案的开发者。
预计阅读时间:10 分钟
最终效果:理解 Fastify 静态文件服务、SPA fallback、CORS 配置和优雅关闭的完整实现。

在开发阶段,前端运行在 Vite 开发服务器上(端口 13721),后端运行在 Fastify 上(端口 3721),两者通过代理通信。但到了生产环境,我们不需要两个服务器——Fastify 可以同时承担 API 服务和静态文件托管的职责。本文将深入分析 ChatCrystal 如何实现这一架构,以及其中涉及的关键技术点。


开发 vs 生产环境的差异

先理解两个环境的核心区别:

开发环境:前后端分离,各自独立运行。

浏览器 → Vite (localhost:13721) → 代理 /apiFastify (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"

这个命令依次执行两个工作空间的构建:

  1. npm run build -w server — 将 TypeScript 编译为 JavaScript,输出到 server/dist/ 目录
  2. 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 中,注册逻辑如下:

import fastifyStatic from '@fastify/static';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';

const candidatePaths = [
  resolve(import.meta.dirname, '../../client/dist'),
  resolve(import.meta.dirname, '../../../../client/dist'),
];
const clientDist = candidatePaths.find((p) => existsSync(p)) ?? candidatePaths[0];

if (existsSync(clientDist)) {
  await app.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 配置非常简洁:

await app.register(cors, { origin: true });

origin: true 表示将请求的 Origin 头作为响应的 Access-Control-Allow-Origin 返回,等同于允许所有来源。

在开发环境中这是必要的:前端运行在 localhost:13721,后端运行在 localhost:3721,端口不同,属于跨域。

在生产环境中,前后端共享同一个端口,同源策略不会生效,CORS 配置实际上不会被触发。但保留它没有副作用,而且如果你将来需要从其他域名访问 API(比如移动端应用),这个配置依然有用。

如果你对安全性有更高要求,生产环境中可以限制 origin 为特定域名:

await app.register(cors, {
  origin: process.env.NODE_ENV === 'production'
    ? 'https://your-domain.com'
    : true,
});

端口和主机配置

服务器启动时的网络配置:

const port = options?.port ?? appConfig.port; // 默认 3721
const host = options?.host ?? '0.0.0.0';
await app.listen({ port, host });

端口:默认 3721,可以通过 options.port 参数或配置文件覆盖。这个设计让 Electron 可以传入自定义端口。

主机0.0.0.0 意味着监听所有网络接口。这在生产环境中很重要——如果设置为 127.0.0.1(仅本机),那么其他设备(如同一局域网内的手机)将无法访问服务。

ChatCrystal 还支持通过环境变量覆盖端口:

PORT=8080 npm start

这在容器化部署(Docker)或需要与其他服务共存时非常有用。


优雅关闭:watcher -> DB -> HTTP

服务器关闭时,不能直接 process.exit(),否则可能导致数据丢失。ChatCrystal 实现了有序的优雅关闭(graceful shutdown):

async function shutdown() {
  console.log('[Server] Shutting down...');
  await watcher.close();   // 第一步:停止文件监听
  closeDatabase();          // 第二步:保存并关闭数据库
  await app.close();        // 第三步:关闭 HTTP 服务器
}

为什么要按这个顺序?

第一步:停止 watcher。文件监听器(chokidar)会在后台持续扫描文件变化。如果先关闭数据库,watcher 触发的导入操作会因为数据库不可用而报错。所以先停止 watcher,确保不会有新的写入请求。

第二步:关闭数据库closeDatabase() 会执行以下操作:

export function closeDatabase(): void {
  stopAutoSave();   // 停止 30 秒自动保存定时器
  if (db) {
    saveDatabase();  // 将内存中的数据写入磁盘
    db.close();      // 释放数据库连接
    db = null;
  }
}

sql.js 是内存数据库,所有修改都保存在 RAM 中,通过定期 saveDatabase() 写入磁盘文件。关闭前必须执行一次最终保存,否则会丢失最近 30 秒内的修改。

第三步:关闭 HTTP 服务器。等待所有正在处理的请求完成后,关闭 TCP 连接。放在最后是因为关闭过程中可能还有请求依赖数据库读取。

在独立模式下,shutdown 通过信号触发:

process.on('SIGINT', handle);   // Ctrl+C
process.on('SIGTERM', handle);  // kill 命令或容器停止

前后端同端口部署的优势

ChatCrystal 将前后端合并到同一个端口,这种架构有几个实际好处:

部署简单。只需要暴露一个端口,不需要配置反向代理(nginx)来分别路由静态文件和 API 请求。对于个人工具类项目,这大大降低了运维复杂度。

避免 CORS 问题。同源请求不受浏览器跨域限制,省去了复杂的 CORS 配置和调试。

Electron 兼容。Electron 桌面应用内部嵌入了同一个 Fastify 服务器。如果前后端分离,Electron 需要同时管理两个进程,复杂度会显著增加。

export async function createServer(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…