Fastify 加 Electron:把 Web 服务嵌进桌面应用
2026/6/23 21:56:48 网站建设 项目流程

本文面向:想把 Web 服务嵌入 Electron 桌面应用的开发者。
预计阅读时间:10 分钟
最终效果:理解 Function() 构造器绕过 CJS 限制、端口检测、CSP 安全头、生命周期管理的完整方案。

同一个服务器,两种运行模式

ChatCrystal 的 Fastify 服务器既可以独立运行(npm start),也可以嵌入 Electron 桌面应用。两种模式共享同一份服务器代码,区别只在启动方式和生命周期管理。

独立模式下,server/src/index.ts底部的自启动逻辑直接调用createServer()

if(!process.env.ELECTRON&&!process.env.CRYSTAL_CLI){createServer().then(({shutdown})=>{consthandle=()=>shutdown().then(()=>process.exit(0));process.on('SIGINT',handle);process.on('SIGTERM',handle);}).catch((err)=>{console.error('Failed to start server:',err);process.exit(1);});}

Electron 模式下,这段代码不会执行——主进程通过ELECTRON环境变量抑制自启动,改为手动调用createServer()并控制生命周期。

Function() 构造器:绕过 CJS 的 ESM 导入

Electron 的主进程默认运行在 CommonJS 模块系统下。但 ChatCrystal 的服务器是 ESM 模块(type: "module"+import.meta)。直接用import()在 CJS 上下文中会被 Electron 的打包器拦截或报错。

解决方案是用Function()构造器创建一个动态导入:

constserverEntry=pathToFileURL(path.join(app.getAppPath(),"server","dist","server","src","index.js"),).href;constserverModule=awaitFunction("specifier","return import(specifier)",)(serverEntry);

Function()构造器创建的函数运行在全局作用域,不受当前模块的 CJS 上下文限制。pathToFileURL()把文件路径转成file://URL,这是 ESMimport()要求的格式。

代码中的注释明确标注了这是一个有意为之的 workaround:

C-1: Function() constructor is used intentionally to bypass Electron’s CJS bundler restrictions on dynamic import(). This is a known workaround for loading ESM server modules from a CJS main process.

如果未来 Electron 主进程迁移到 ESM,可以直接用import()替换。

端口检测:优雅降级

桌面应用不能假设端口一定可用。用户可能同时运行开发服务器,或者端口被其他程序占用。ChatCrystal 的端口检测逻辑:

functionfindFreePort(preferred:number):Promise<number>{returnnewPromise((resolve,reject)=>{constsrv=net.createServer();srv.listen(preferred,"127.0.0.1",()=>{srv.close(()=>resolve(preferred));});srv.on("error",()=>{constsrv2=net.createServer();srv2.listen(0,"127.0.0.1",()=>{constport=(srv2.address()asnet.AddressInfo).port;srv2.close(()=>resolve(port));});});});}

先尝试首选端口 3721。如果被占用,监听端口 0 让操作系统分配随机可用端口。主进程在启动服务器前调用这个函数:

serverPort=awaitfindFreePort(3721);if(serverPort!==3721){console.log(`[Electron] Port 3721 occupied, using port${serverPort}`);}

找到端口后,传递给createServer({ port, host: "127.0.0.1" })。host 绑定到127.0.0.1而不是0.0.0.0,确保桌面应用的服务器只监听本地回环地址,不暴露到网络。

CSP 安全头:生产环境的 XSS 防护

AI 对话内容会被渲染成 Markdown,其中可能包含恶意脚本。ChatCrystal 在 Electron 的生产模式下注入严格的 Content-Security-Policy 响应头:

if(!process.env.VITE_DEV_URL){session.defaultSession.webRequest.onHeadersReceived((details,callback)=>{callback({responseHeaders:{...details.responseHeaders,"Content-Security-Policy":["default-src 'self';"+" script-src 'self';"+" style-src 'self' 'unsafe-inline';"+" img-src 'self' data: blob:;"+" font-src 'self' data:;"+" connect-src 'self' http://localhost:* ws://localhost:*;"+" object-src 'none';"+" base-uri 'self'",],},});});}

关键限制:script-src 'self'只允许加载同源脚本,阻止内联脚本和eval()style-src 'unsafe-inline'是为了兼容 Tailwind CSS 的运行时样式注入。connect-src限制为 localhost,因为服务器只在本地运行。

开发模式下跳过 CSP,因为 Vite 的 HMR(热模块替换)依赖内联脚本注入。

生命周期管理:启动、运行、关闭

启动流程

app.whenReady()回调中按顺序执行 9 个步骤:

  1. 确定数据目录(DATA_DIR环境变量或~/.chatcrystal/data
  2. 确保数据目录存在(mkdirSync
  3. 设置环境变量(ELECTRON=true,DATA_DIR,ELECTRON_PACKAGED
  4. 注入 CSP 安全头(生产模式)
  5. 检测可用端口
  6. 启动 Fastify 服务器(开发模式跳过——服务器由tsx独立运行)
  7. 创建 BrowserWindow
  8. 加载应用 URL(开发模式加载VITE_DEV_URL,生产模式加载http://localhost:{port}
  9. 创建系统托盘

步骤 6 的条件判断实现了开发/生产模式的无缝切换。开发时 Electron 窗口连 Vite 开发服务器(带 HMR),生产时连嵌入的 Fastify 服务器。

运行时行为

窗口关闭不退出应用——而是隐藏到系统托盘:

win.on("close",(e)=>{saveWindowState(win);if(!isQuitting){e.preventDefault();win.hide();}});

系统托盘提供右键菜单:打开窗口、搜索知识、浏览器打开、退出。双击托盘图标也能打开窗口。

优雅关闭

退出时的关闭顺序是:watcher 停止 → 数据库保存 → Fastify 关闭 → 托盘销毁:

asyncfunctiongracefulShutdown():Promise<void>{if(serverShutdown){awaitserverShutdown();serverShutdown=null;}destroyTray();}

serverShutdowncreateServer()返回的 shutdown 函数,内部依次执行watcher.close()closeDatabase()app.close()

before-quit事件处理器还有一个 10 秒超时机制——如果关闭过程卡住,强制退出:

consttimeout=setTimeout(()=>{console.error("[Electron] Shutdown timed out, forcing exit");app.exit(1);},10000);

窗口状态持久化

窗口位置和大小保存到%APPDATA%/ChatCrystal/window-state.json。每次 resize/move 事件都更新内存中的状态,close 事件时写入文件。

恢复时会验证保存的位置是否在当前显示器范围内——如果用户之前接了外接显示器,现在拔掉了,窗口不会跑到屏幕外面:

constdisplays=screen.getAllDisplays();constvisible=displays.some((d)=>{constb=d.bounds;returnstate.x!>=b.x-50&&state.x!<b.x+b.width&&state.y!>=b.y-50&&state.y!<b.y+b.height;});if(!visible){state.x=undefined;state.y=undefined;}

50 像素的容差允许窗口边缘稍微超出屏幕(用户可能故意把窗口部分隐藏在屏幕边缘)。

单实例锁

app.requestSingleInstanceLock()确保只有一个 ChatCrystal 实例运行。如果用户尝试启动第二个实例,主实例会收到second-instance事件,把窗口恢复并聚焦:

app.on("second-instance",()=>{if(mainWindow){if(mainWindow.isMinimized())mainWindow.restore();mainWindow.show();mainWindow.focus();}});

第二个实例直接退出。

总结

ChatCrystal 的 Electron 集成围绕一个核心思想:同一个服务器,不同的启动器。Fastify 服务器通过createServer()导出,独立模式和 Electron 模式共享完全相同的代码。Function() 构造器解决了 CJS/ESM 模块系统的兼容问题,端口检测保证了桌面环境的健壮性,CSP 安全头防止了 AI 对话内容中的 XSS 攻击。整个生命周期从启动到关闭都有完善的错误处理和超时机制。


项目地址:github.com/ZengLiangYi/ChatCrystal

如有疑问欢迎在 GitHub Issues 或私信交流,很乐意解答。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询