---
title: Fastify 与 Electron：桌面服务的启动和退出边界
description: 跟踪 ChatCrystal 的本地、云端与引导模式，验证 CommonJS 动态加载和端口竞争，核对窗口、安全头及服务关闭顺序。
image: "https://zengliangyi.com/og/blog/fastify-electron-service-lifecycle.png"
---

桌面应用复用 Web 服务，关键是让窗口、监听端口和数据资源具有明确的生命周期。ChatCrystal 的固定实现已经包含本地、云端和引导模式，不能再按“一启动就创建本地服务”的单一路径介绍。本文沿当前代码核对启动、加载和退出，并用独立样本观察模块加载与端口竞争。

## 服务工厂和桌面入口分别管理什么

[server/src/index.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/index.ts)导出 createServer，初始化数据库和自动保存，配置 cloud/auth、路由及存在时的前端静态目录，再 await app.listen，返回 app、port 和 shutdown。直接运行服务时使用配置端口，默认 host 是 0.0.0.0；监听所有接口和桌面本地绑定不是同一暴露范围。

[electron/main.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/electron/main.ts)取得单实例锁，在运行时设置 ELECTRON、DATA_DIR，打包态还设置 ELECTRON_PACKAGED。本地生产路径加载编译后的服务，并传 host=127.0.0.1、startWatcher=false。不要写成桌面嵌入服务必定开启文件 watcher；[watcherPolicy.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/server/src/runtime/watcherPolicy.ts)还规定云模式不启动它。

| 模式或条件 | 当前路径 |

| --- | --- |

| 本地生产模式 | ensureLocalCoreStarted 在尚无嵌入服务时探测端口、创建服务，再加载本地窗口 |

| VITE_DEV_URL 开发模式 | 加载开发页面，API 地址取外部开发 core；此路径不创建嵌入 core |

| 有已保存云端 URL/token 的云模式 | 加载云端地址并检查窗口来源，再注入认证状态；此打开路径不创建本地 core |

| 未选模式或模式打开失败 | 打开引导页，失败路径可显示错误信息 |

“云模式这次不启动 core”不等于“此前启动的本地 core 一定已关闭”。判断资源寿命还要跟踪模式切换与退出，不能只看打开窗口的函数。

## CommonJS 可以动态加载 ESM

当前 importServerModule 检查编译产物路径，用 pathToFileURL 生成地址，再通过 Function 中的 `import(specifier)` 加载模块。这是仓库采用的写法，不是“CommonJS 无法原生动态加载 ESM”的证明。[Node 模块文档](https://nodejs.org/download/release/v24.21.0/docs/api/esm.html)说明动态 import 可在 CommonJS 中使用；[Electron ESM 文档](https://www.electronjs.org/docs/latest/tutorial/esm)也要求区分主进程、渲染器和 preload 的加载环境与异步时序。

用 Node 24+ 做一个不涉及 Electron 或 ChatCrystal 的练习。先保存 `fixture.mjs`：



```js
export   async   function   ready  () {  
    return   { ready:   true   };  
  }
```



再保存同目录的 `lesson.cjs`，运行 `node lesson.cjs`：



```js
const   assert   =   require  (  'node:assert/strict'  );  
  const   path   =   require  (  'node:path'  );  
  const   {   pathToFileURL   }   =   require  (  'node:url'  );  
  
  (  async   ()   =>   {  
    const   url   =   pathToFileURL  (path.  join  (__dirname,   'fixture.mjs'  )).href;  
    const   service   =   await   import  (url);  
    assert.  deepEqual  (  await   service.  ready  (), { ready:   true   });  
    console.  log  (  'CommonJS dynamic ESM import passes'  );  
  })().  catch  ((  error  )   =>   {  
    console.  error  (error);  
    process.exitCode   =   1  ;  
  });
```



把 fixture 的 ready 值改为 false，断言失败且进程返回非零；恢复后通过。这只验证 Node 的模块与等待行为，没有运行桌面主进程、preload 或打包产物。编译设置和实际输出仍应结合[TypeScript 配置](/blog/typescript-monorepo-configuration/)验收。

## 探测空闲端口不等于保留端口

findFreePort 先尝试在 127.0.0.1 监听偏好端口，关闭探测 server 后返回端口；探测出错则尝试 port=0。这不是一个跨关闭和重新监听的预约：其他进程可以在两步之间占用端口，且偏好探测的任意错误都会进入回退，不只是地址已占用。

在独立目录固定安装 `npm install --ignore-scripts --save-exact fastify@5.12.3`，保存下面的 `port-lesson.mjs`，用 Node 24+ 运行。它只启动回环上的教学服务，**不启动实际应用或读写用户数据**。



```js
import   assert   from   'node:assert/strict'  ;  
  import   net   from   'node:net'  ;  
  import   { once }   from   'node:events'  ;  
  import   Fastify   from   'fastify'  ;  
  
  async   function   listen  (  server  ,   port  ) {  
    server.  listen  ({ host:   '127.0.0.1'  , port });  
    await   once  (server,   'listening'  );  
    return   server.  address  ().port;  
  }  
  function   close  (  server  ) {  
    return   new   Promise  ((  resolve  ,   reject  )   =>  
      server.  close  ((  error  )   =>   (error   ?   reject  (error)   :   resolve  ()))  
    );  
  }  
  
  const   probe   =   net.  createServer  ();  
  const   port   =   await   listen  (probe,   0  );  
  await   close  (probe);  
  const   other   =   net.  createServer  ();  
  const   conflict   =   Fastify  ();  
  const   retained   =   Fastify  ();  
  try   {  
    await   listen  (other, port);  
    await   assert.  rejects  (conflict.  listen  ({ host:   '127.0.0.1'  , port }), { code:   'EADDRINUSE'   });  
    retained.  get  (  '/health'  ,   async   ()   =>   ({ ready:   true   }));  
    const   address   =   await   retained.  listen  ({ host:   '127.0.0.1'  , port:   0   });  
    const   response   =   await   fetch  (address   +   '/health'  );  
    assert.  equal  (response.status,   200  );  
    assert.  deepEqual  (  await   response.  json  (), { ready:   true   });  
    console.  log  (  'released probe and retained listener boundaries pass'  );  
  }   finally   {  
    await   conflict.  close  ();  
    await   retained.  close  ();  
    await   close  (other);  
  }
```



这里故意让 another listener 占住已释放的探测端口，实际观察 EADDRINUSE。再由 Fastify 直接监听 port=0，并保留该监听器，读取实际地址后访问 health。把就绪响应期望改成 false 会失败；恢复后通过。直接保留监听器是教学方案，当前 ChatCrystal 没有因此改成这条路径。[Fastify listen 文档](https://fastify.dev/docs/latest/Reference/Server/#listen)说明了 port=0 和返回 Promise 的行为。

## 窗口和安全头必须按环境核对

createWindow 根据模式选择 preload，当前设置 contextIsolation=true、nodeIntegration=false、sandbox=true。导航限制和来源检查还各有职责；这些设置不构成所有输入都安全的证明。

configureLocalContentSecurityPolicy 跳过 VITE_DEV_URL 开发态，根据响应 URL 是否为本地 HTTP 来源决定是否覆盖 CSP，不检查窗口模式或端口。非本地 HTTP 来源的云端响应不会进入这条覆盖逻辑；若云模式配置了 [http://localhost，仍可能进入。策略包含](http://localhost%EF%BC%8C%E4%BB%8D%E5%8F%AF%E8%83%BD%E8%BF%9B%E5%85%A5%E3%80%82%E7%AD%96%E7%95%A5%E5%8C%85%E5%90%AB) style-src 的 unsafe-inline 及指定本地连接规则；不能把“存在 CSP”写成彻底消除 XSS。Markdown 渲染、外链、IPC 和云端来源仍有各自输入边界。

关闭窗口时保存状态，尚未退出则 preventDefault 并隐藏；window-all-closed 也保留进程。第二个实例会唤起窗口。保存坐标超出所有显示器检查范围时会清除位置；宽高与可见性规则仍需真实多屏桌面验收，不宜把这些代码检查当成已完成桌面实测。

## 退出有顺序，也有失败和超时出口

服务 shutdown 依次 await watcher.close、closeDatabase、await app.close，并没有 finally 保证前一步抛错后仍执行后续步骤。桌面 before-quit 会阻止第一次退出，设置退出标志，启动十秒强制退出计时器，并尝试终止远端导入 worker、关闭已有本地服务、销毁托盘。finally 清除计时器并 app.quit；超时则 app.exit(1)。

因此“有优雅退出代码”不能升级成“任何异常下都保存成功”。分别验收监听失败、服务加载失败、窗口隐藏、正常退出、关闭步骤抛错和关闭超时，并检查数据是否实际落盘。[测试策略](/blog/testing-strategy/)中的失败路径思路也适用于这里；本文的 Node 和回环练习不替代真实 Electron 安装包验收。

分享：

##  相关文章

[##  Electron 托盘与窗口状态：隐藏、恢复和退出的边界

对照 ChatCrystal 当前托盘菜单、窗口位置校验与退出流程，理解状态持久化能恢复什么，以及仍需测试的失败场景。          曾亮懿       2026年10月5日      阅读约 4 分钟

#ChatCrystal

#Electron

#Node.js](/blog/electron-tray-window-state)    [##  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 与 Electron：桌面服务的启动和退出边界",
  "description": "跟踪 ChatCrystal 的本地、云端与引导模式，验证 CommonJS 动态加载和端口竞争，核对窗口、安全头及服务关闭顺序。",
  "url": "https://zengliangyi.com/blog/fastify-electron-service-lifecycle/",
  "image": "https://zengliangyi.com/images/blog/fastify-electron-service-lifecycle.svg",
  "datePublished": "2026-10-07T00:00:00.000Z",
  "dateModified": "2026-10-08T00: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-electron-service-lifecycle/"
  }
}
{
  "@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 与 Electron：桌面服务的启动和退出边界",
      "item": "https://zengliangyi.com/blog/fastify-electron-service-lifecycle/"
    }
  ]
}
```
