开发时 Vite 与 Fastify 分开运行;生产时,存在前端产物的 Fastify 也能提供页面。这里的“同端口”描述请求入口,不等于构建已经包含页面、任意 API 都公开或退出时所有请求都安全完成。本文按固定 createServer 实现 逐项验证这些边界;框架基础见 Fastify 插件与输入校验。
从构建产物确认页面是否存在
根目录脚本 当前使用 pnpm,build 依次执行 server 与 client 的过滤构建;不是原稿的 npm workspace 命令。已有依赖且使用项目要求的 Node 24+ 时,在该固定 checkout 中可运行:
pnpm build
test -f client/dist/index.html
server 的 TypeScript 构建输出位于 server/dist/server/src/,client 的 Vite 产物位于 client/dist/。发布包文件列表 主要包含 dist/server 和 dist/shared,没有自动携带源码仓库的 client/dist。不能从“CLI 安装成功”推导“浏览器页面已打包”。
入口根据 import.meta.dirname 探测两个路径:源码布局向上两级到 client/dist,编译布局向上四级到同一位置。找到的条件是目录路径 existsSync,没有验证 index.html 或完整资源集合。只有目录存在时,才注册静态插件与自定义 fallback;空目录也能进入这一分支,之后提供首页仍会失败。
wildcard 与 fallback 可以共存
当前注册只传 root 和 prefix: '/',没有 wildcard: false。静态插件文档 的默认值为 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 测试,不监听网络端口。
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 配置:前端监听 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。详见固定版本的 认证钩子。
shutdown 的实际顺序不等于全部请求已排空
服务 shutdown 依次 await watcher.close、closeDatabase、await app.close。数据库关闭包含停止自动保存、最后导出写盘与释放内存数据库。但 HTTP close 在数据库关闭之后,不能把这一顺序解释为“先等待所有在途请求完成,再安全关闭数据库”。停止 watcher 也没有自动等待所有已排队 AI 任务。
独立入口通过 SIGINT/SIGTERM 调用该函数,Electron 通过返回的 shutdown 回调调用它。实际部署验收应覆盖缺少前端目录、目录存在但首页缺失、未知 API、缺失 JS 文件、未认证云端请求和关闭时的在途操作,并记录各自响应或退出结果。