---
title: Fastify 生产静态服务：同端口、SPA fallback 与失败路径
description: 核对 ChatCrystal 的实际构建与静态目录探测，用隔离 inject 练习验证页面、未知 API 和缺失资源的不同响应。
image: "https://zengliangyi.com/og/blog/fastify-production-static-service.png"
---

开发时 Vite 与 Fastify 分开运行；生产时，存在前端产物的 Fastify 也能提供页面。这里的“同端口”描述请求入口，不等于构建已经包含页面、任意 API 都公开或退出时所有请求都安全完成。本文按固定 [createServer 实现](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/index.ts) 逐项验证这些边界；框架基础见 [Fastify 插件与输入校验](/blog/learn-fastify-with-chatcrystal/)。

## 从构建产物确认页面是否存在

[根目录脚本](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/package.json) 当前使用 pnpm，`build` 依次执行 server 与 client 的过滤构建；不是原稿的 npm workspace 命令。已有依赖且使用项目要求的 Node 24+ 时，在该固定 checkout 中可运行：



```bash
pnpm   build  
  test   -f   client/dist/index.html
```



server 的 TypeScript 构建输出位于 `server/dist/server/src/`，client 的 Vite 产物位于 `client/dist/`。[发布包文件列表](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/package.json) 主要包含 dist/server 和 dist/shared，没有自动携带源码仓库的 client/dist。不能从“CLI 安装成功”推导“浏览器页面已打包”。

入口根据 `import.meta.dirname` 探测两个路径：源码布局向上两级到 client/dist，编译布局向上四级到同一位置。找到的条件是目录路径 `existsSync`，没有验证 index.html 或完整资源集合。只有目录存在时，才注册静态插件与自定义 fallback；空目录也能进入这一分支，之后提供首页仍会失败。

## wildcard 与 fallback 可以共存

当前注册只传 `root` 和 `prefix: '/'`，没有 `wildcard: false`。[静态插件文档](https://github.com/fastify/fastify-static#wildcard) 的默认值为 true。原稿所称“必须关闭 wildcard 才能 fallback”不符合此入口：静态文件未找到时仍可进入该 not-found 处理器。

处理器对原始 `req.url.startsWith('/api/')` 做判断：匹配则返回 JSON 404，否则 sendFile(‘index.html’)。这是字面前缀，不是所有 API 风格地址的抽象分类；例如 `/api` 和 `/api?x=1` 都不匹配这个判断。

更容易遗漏的是 `/missing.js`：它也没有 `/api/` 前缀，因此会收到首页 HTML。HTTP 200 不说明脚本可执行，浏览器可能因 MIME 或语法拒绝它。线上排查要同时看状态、Content-Type 与响应体，不能只用“刷新后不再 404”判断部署正常。

## 隔离练习：不用启动真实知识库

以下是独立教学程序，使用 Fastify 5.12.3 和 @fastify/static 10.1.3；没有 ChatCrystal 的数据库、导入、认证或文件监听。请在新的临时练习目录安装这两个精确版本，将代码保存为 `lesson.mjs` 后用 Node 24+ 运行。程序只在该目录写一个 public/index.html，并用 inject 测试，不监听网络端口。



```js
import   assert   from   'node:assert/strict'  ;  
  import   { mkdirSync, writeFileSync }   from   'node:fs'  ;  
  import   { resolve, join }   from   'node:path'  ;  
  import   Fastify   from   'fastify'  ;  
  import   fastifyStatic   from   '@fastify/static'  ;  
  
  const   root   =   resolve  (  'public'  );  
  mkdirSync  (root, { recursive:   true   });  
  writeFileSync  (  join  (root,   'index.html'  ),   '<!doctype html><p>lesson shell</p>'  );  
  const   app   =   Fastify  ();  
  app.  get  (  '/api/ping'  ,   async   ()   =>   ({ success:   true   }));  
  await   app.  register  (fastifyStatic, { root, prefix:   '/'   });  
  app.  setNotFoundHandler  ((  req  ,   reply  )   =>   {  
    if   (req.url.  startsWith  (  '/api/'  )) {  
      return   reply.  code  (  404  ).  send  ({ success:   false  , error:   'Not Found'   });  
    }  
    return   reply.  sendFile  (  'index.html'  );  
  });  
  
  try   {  
    const   api   =   await   app.  inject  (  '/api/ping'  );  
    assert.  equal  (api.statusCode,   200  );  
    assert.  deepEqual  (api.  json  (), { success:   true   });  
    const   unknownApi   =   await   app.  inject  (  '/api/unknown'  );  
    assert.  equal  (unknownApi.statusCode,   404  );  
    assert.  deepEqual  (unknownApi.  json  (), { success:   false  , error:   'Not Found'   });  
    for   (  const   url   of   [  '/notes/123'  ,   '/missing.js'  ,   '/api'  ,   '/api?x=1'  ]) {  
      const   response   =   await   app.  inject  (url);  
      assert.  equal  (response.statusCode,   200  );  
      assert.  match  (response.headers[  'content-type'  ],   /  text  \/  html  /  );  
      assert.  match  (response.body,   /  lesson shell  /  );  
    }  
  }   finally   {  
    await   app.  close  ();  
  }
```



刻意把 handler 的 API 判断删掉，unknownApi 的 404 断言应失败；恢复后应通过。检查缺失首页时，先注释初始化的 writeFileSync 行，再移除 public/index.html 后运行；否则程序会重建首页，无法触发这个失败路径。这个练习验证插件与该 fallback 组合的行为，不证明项目里的云端 hook、客户端路由或真实导入已经测试通过。

## 真实服务还受模式与监听地址约束

开发代理来自 [Vite 配置](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/vite.config.ts)：前端监听 127.0.0.1:13721，`/api` 代理到 localhost:3721。生产 API 与页面若同源，通常不需要为这类请求通过跨源许可；源码的 CORS `origin: true` 仍然注册，不能说它只在开发时生效。跨源调用会按这份配置处理，CORS 也不替代认证。

`createServer` 的端口来自 options.port 或配置，配置读取 `Number(PORT) || 3721`；host 默认为 0.0.0.0，Electron 本地模式另传 127.0.0.1。未知 `/api/` 请求在本地或通过云端认证后才到 JSON 404；云端尚未完成 setup 可能先得到 403，缺少有效凭证可能先得到 401。详见固定版本的 [认证钩子](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/routes/setup.ts)。

## shutdown 的实际顺序不等于全部请求已排空

服务 shutdown 依次 await watcher.close、closeDatabase、await app.close。数据库关闭包含停止自动保存、最后导出写盘与释放内存数据库。但 HTTP close 在数据库关闭之后，不能把这一顺序解释为“先等待所有在途请求完成，再安全关闭数据库”。停止 watcher 也没有自动等待所有已排队 AI 任务。

独立入口通过 SIGINT/SIGTERM 调用该函数，Electron 通过返回的 shutdown 回调调用它。实际部署验收应覆盖缺少前端目录、目录存在但首页缺失、未知 API、缺失 JS 文件、未认证云端请求和关闭时的在途操作，并记录各自响应或退出结果。

分享：

##  相关文章

[##  从 ChatCrystal 学 Fastify：插件、校验与失败路径

对照真实 createServer 与路由边界，在隔离练习中运行 Fastify inject，验证成功、无效输入和不存在的路由。          曾亮懿       2026年10月4日      阅读约 3 分钟

#ChatCrystal

#Fastify

#Node.js](/blog/learn-fastify-with-chatcrystal)    [##  AI 对话格式差异：事件、快照与跨库消息定位

对照五个固定适配器，区分 JSONL 容器、事件语义与 SQLite 键布局，用两个内存数据库观察消息索引、孤立数据和未知版本。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-format-divergence)    [##  AI 对话管理方案：四种存储路径与迁移验收

比较 Claude Code、Cursor、Copilot、Trae 的固定适配器输入，区分原始副本、可读导出与知识库，并用合成清单验证身份和信息保留。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#SQLite](/blog/ai-conversation-management-options)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "Fastify 生产静态服务：同端口、SPA fallback 与失败路径",
  "description": "核对 ChatCrystal 的实际构建与静态目录探测，用隔离 inject 练习验证页面、未知 API 和缺失资源的不同响应。",
  "url": "https://zengliangyi.com/blog/fastify-production-static-service/",
  "image": "https://zengliangyi.com/images/blog/fastify-production-static-service.svg",
  "datePublished": "2026-10-05T00:00:00.000Z",
  "dateModified": "2026-10-05T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/fastify-production-static-service/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "Fastify 生产静态服务：同端口、SPA fallback 与失败路径",
      "item": "https://zengliangyi.com/blog/fastify-production-static-service/"
    }
  ]
}
```
